Skip to main content

grabcli_core/
login.rs

1//! `grab auth login`: a real phone login on weblogin.grab.com, driven over
2//! CDP inside an **isolated browser context**.
3//!
4//! grabcli does not replay or forge the login page's anti-bot tokens
5//! (reCAPTCHA Enterprise, the `x-hydraweb-jwt` fingerprint). Instead it opens
6//! `food.grab.com/auth/login` in a fresh `Target.createBrowserContext`
7//! context (own cookie jar, shared with no other tab, so nothing else in the
8//! browser can log it out) and lets the real page do its own checks, the way
9//! a person would. grabcli only:
10//!
11//! 1. types the phone number and keeps "Remember me … 60 days" ticked;
12//! 2. clicks Continue and, if Grab offers a choice, picks ONE challenge
13//!    (in-app approval or OTP) — this is the only step that sends anything;
14//! 3. later (same or another process) types the OTP, or waits for the
15//!    in-app approval;
16//! 4. lets the page redirect through `food.grab.com/auth/callback` inside the
17//!    context, reads the context's cookies with `Storage.getCookies`, saves
18//!    them as the session and disposes the context.
19//!
20//! The context outlives the process (`disposeOnDetach: false`); what's needed
21//! to resume (context/tab ids, expiry, the masked phone) is kept 0600 in
22//! [`PendingLogin`]. Sends are rate-limited by [`SendLog`] and never retried.
23//! Nothing here prints cookie values, tokens, OTPs or the full phone number.
24
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28use serde::{Deserialize, Serialize};
29use serde_json::{Value, json};
30
31use crate::cdp::{self, Browser, PageInfo};
32use crate::error::{Error, ErrorKind, Result};
33use crate::jwt;
34use crate::paths;
35use crate::session::{LOCATION_COOKIE, Session};
36use crate::sso;
37
38/// Where the browser login starts (redirects to weblogin.grab.com).
39pub const LOGIN_START_URL: &str = sso::FOOD_LOGIN;
40/// How long a started login stays resumable. Grab's codes live ~5 min.
41pub const PENDING_TTL_SECS: i64 = 10 * 60;
42/// Minimum gap between two challenge sends.
43pub const SEND_COOLDOWN_SECS: i64 = 60;
44/// Maximum challenge sends in any rolling hour.
45pub const MAX_SENDS_PER_HOUR: usize = 3;
46
47// ------------------------------------------------------------------- phone
48
49/// Calling codes of the markets Grab serves, longest first.
50const CALLING_CODES: &[&str] = &["855", "60", "62", "63", "65", "66", "84", "95"];
51
52/// Split `+<cc><national>` into (`cc`, national digits). Spaces, dashes,
53/// dots and parentheses are ignored; a trunk `0` is dropped.
54pub fn split_phone(phone: &str) -> Result<(String, String)> {
55    let cleaned: String = phone
56        .chars()
57        .filter(|c| !matches!(c, ' ' | '-' | '.' | '(' | ')'))
58        .collect();
59    let digits = cleaned.strip_prefix('+').ok_or_else(|| {
60        Error::invalid("phone must be in international form, e.g. +63 9XX XXX XXXX")
61    })?;
62    if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
63        return Err(Error::invalid("phone must contain only digits after the +"));
64    }
65    let cc = CALLING_CODES
66        .iter()
67        .find(|cc| digits.starts_with(**cc))
68        .ok_or_else(|| Error::invalid("phone country code is not a Grab market"))?;
69    let national = digits[cc.len()..].trim_start_matches('0').to_string();
70    if !(6..=12).contains(&national.len()) {
71        return Err(Error::invalid("phone number has the wrong length"));
72    }
73    Ok(((*cc).to_string(), national))
74}
75
76/// `+63••••••••12`: the country code and the last two digits only.
77pub fn mask_phone(cc: &str, national: &str) -> String {
78    let n = national.len();
79    let keep = n.min(2);
80    format!("+{cc}{}{}", "•".repeat(n - keep), &national[n - keep..])
81}
82
83// ------------------------------------------------------------------ channel
84
85/// Which challenge to ask Grab for.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum Channel {
89    /// Approve in the Grab app (push).
90    App,
91    /// A one-time code by SMS.
92    Sms,
93    /// A one-time code by WhatsApp.
94    Whatsapp,
95}
96
97impl Channel {
98    pub fn parse(s: &str) -> Result<Self> {
99        match s.to_ascii_lowercase().as_str() {
100            "app" | "push" => Ok(Self::App),
101            "sms" => Ok(Self::Sms),
102            "whatsapp" | "wa" => Ok(Self::Whatsapp),
103            _ => Err(Error::invalid("--channel must be app, sms or whatsapp")),
104        }
105    }
106
107    pub fn as_str(self) -> &'static str {
108        match self {
109            Self::App => "app",
110            Self::Sms => "sms",
111            Self::Whatsapp => "whatsapp",
112        }
113    }
114
115    /// What one send produces, in words.
116    pub fn describe(self) -> &'static str {
117        match self {
118            Self::App => "one approval request in the Grab app",
119            Self::Sms | Self::Whatsapp => {
120                "one OTP code (Grab chooses SMS or WhatsApp delivery; the CLI never resends to switch)"
121            }
122        }
123    }
124
125    /// weblogin challenge types (the `Proceed to <TYPE>` options) that
126    /// satisfy this channel, best first.
127    pub fn challenge_types(self) -> &'static [&'static str] {
128        match self {
129            Self::App => &["PUSH_V2", "PUSH"],
130            Self::Sms | Self::Whatsapp => &["CHANNEL_OPTIMIZED_PHONE_OTP", "SMS_OTP"],
131        }
132    }
133}
134
135// ------------------------------------------------------------ pending state
136
137/// What the send produced.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum PendingKind {
141    /// A code was sent (SMS/WhatsApp); finish with `--otp`.
142    OtpSent,
143    /// An in-app approval was requested; finish with `--poll`.
144    ApprovalPending,
145}
146
147impl PendingKind {
148    pub fn as_str(self) -> &'static str {
149        match self {
150            Self::OtpSent => "otp_sent",
151            Self::ApprovalPending => "approval_pending",
152        }
153    }
154}
155
156/// State kept between `--phone …` and `--otp` / `--poll`. No secrets:
157/// ids of the isolated context/tab in the browser and a masked phone.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct PendingLogin {
160    pub kind: PendingKind,
161    pub cdp_base: String,
162    pub browser_context_id: String,
163    pub target_id: String,
164    pub created_at: i64,
165    pub expires_at: i64,
166    pub phone_masked: String,
167    pub channel_requested: Channel,
168    /// Delivery Grab reported on the page (`SMS`, `WhatsApp`, `app`), if known.
169    #[serde(default)]
170    pub channel_sent: Option<String>,
171}
172
173impl PendingLogin {
174    pub fn path() -> Result<PathBuf> {
175        Ok(paths::state_dir()?.join("login-pending.json"))
176    }
177
178    /// The saved pending login, expired or not; `None` if there is none.
179    pub fn read_from(path: &Path) -> Result<Option<Self>> {
180        match std::fs::read(path) {
181            Ok(b) => Ok(Some(serde_json::from_slice(&b)?)),
182            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
183            Err(e) => Err(e.into()),
184        }
185    }
186
187    pub fn read() -> Result<Option<Self>> {
188        Self::read_from(&Self::path()?)
189    }
190
191    pub fn save_to(&self, path: &Path) -> Result<()> {
192        paths::write_private(path, &serde_json::to_vec_pretty(self)?)
193    }
194
195    pub fn save(&self) -> Result<()> {
196        self.save_to(&Self::path()?)
197    }
198
199    pub fn clear() -> Result<()> {
200        let path = Self::path()?;
201        match std::fs::remove_file(path) {
202            Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
203            _ => Ok(()),
204        }
205    }
206
207    pub fn is_expired(&self, now: i64) -> bool {
208        self.expires_at <= now
209    }
210
211    /// The `--json` document for a pending login: `otp_sent` or
212    /// `approval_pending`, with the command that finishes it.
213    pub fn next_command(&self) -> &'static str {
214        match self.kind {
215            PendingKind::OtpSent => "grab auth login --otp <code>",
216            PendingKind::ApprovalPending => "grab auth login --poll",
217        }
218    }
219
220    pub fn summary(&self, now: i64) -> Value {
221        json!({
222            "kind": self.kind.as_str(),
223            "phone": self.phone_masked,
224            "channel_requested": self.channel_requested.as_str(),
225            "channel_sent": self.channel_sent,
226            "expires_in_secs": (self.expires_at - now).max(0),
227            "expired": self.is_expired(now),
228        })
229    }
230}
231
232/// stdout document after `--phone` (or a `--poll` that is still waiting).
233pub fn pending_json(p: &PendingLogin, now: i64) -> Value {
234    let mut v = p.summary(now);
235    v["expires_at"] = json!(p.expires_at);
236    v["next"] = json!(p.next_command());
237    v
238}
239
240// ---------------------------------------------------------------- send log
241
242/// Timestamps of challenge sends, kept separately from the pending login so
243/// cancelling a login never resets the limits.
244#[derive(Debug, Clone, Default, Serialize, Deserialize)]
245pub struct SendLog {
246    pub sends: Vec<i64>,
247}
248
249impl SendLog {
250    pub fn path() -> Result<PathBuf> {
251        Ok(paths::state_dir()?.join("login-sends.json"))
252    }
253
254    pub fn load_from(path: &Path) -> Result<Self> {
255        match std::fs::read(path) {
256            Ok(b) => Ok(serde_json::from_slice(&b).unwrap_or_default()),
257            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
258            Err(e) => Err(e.into()),
259        }
260    }
261
262    pub fn load() -> Result<Self> {
263        Self::load_from(&Self::path()?)
264    }
265
266    pub fn save_to(&self, path: &Path) -> Result<()> {
267        paths::write_private(path, &serde_json::to_vec_pretty(self)?)
268    }
269
270    pub fn save(&self) -> Result<()> {
271        self.save_to(&Self::path()?)
272    }
273
274    fn recent(&self, now: i64) -> impl Iterator<Item = i64> + '_ {
275        self.sends.iter().copied().filter(move |t| now - t < 3600)
276    }
277
278    pub fn sends_last_hour(&self, now: i64) -> usize {
279        self.recent(now).count()
280    }
281
282    /// Seconds until another send is allowed (0 = now).
283    pub fn wait_secs(&self, now: i64) -> i64 {
284        let mut recent: Vec<i64> = self.recent(now).collect();
285        recent.sort_unstable();
286        let cooldown = recent
287            .last()
288            .map_or(0, |last| last + SEND_COOLDOWN_SECS - now);
289        let hourly = if recent.len() >= MAX_SENDS_PER_HOUR {
290            recent[recent.len() - MAX_SENDS_PER_HOUR] + 3600 - now
291        } else {
292            0
293        };
294        cooldown.max(hourly).max(0)
295    }
296
297    /// Fail with `rate_limited` if a send now would break the cooldown or
298    /// the hourly cap.
299    pub fn check(&self, now: i64) -> Result<()> {
300        let wait = self.wait_secs(now);
301        if wait == 0 {
302            return Ok(());
303        }
304        let why = if self.sends_last_hour(now) >= MAX_SENDS_PER_HOUR {
305            format!("{MAX_SENDS_PER_HOUR} login challenges were already sent in the last hour")
306        } else {
307            format!("a login challenge was sent less than {SEND_COOLDOWN_SECS}s ago")
308        };
309        Err(Error::new(
310            ErrorKind::RateLimited,
311            format!("{why}; nothing was sent. Try again in {wait}s."),
312        )
313        .with_details(json!({"retry_after_secs": wait})))
314    }
315
316    /// Record a send (before it happens: a crash still counts) and drop
317    /// entries older than an hour.
318    pub fn record(&mut self, now: i64) {
319        self.sends.retain(|t| now - t < 3600);
320        self.sends.push(now);
321    }
322}
323
324// ------------------------------------------------------- shared-tab warning
325
326/// food.grab.com tabs in the shared browser that poll order tracking. When
327/// the browser's food session lapses, such a tab gets 401s from `/track` and
328/// the page then calls GrabID `oauth2/logout` + food `/auth/logout` (every
329/// ~16 s), which logs the browser out again right after any login there.
330/// The isolated login context is immune, but these tabs still break
331/// `grab auth import` and normal browsing. grabcli never closes them.
332pub fn order_tracking_tabs(pages: &[PageInfo]) -> Vec<String> {
333    pages
334        .iter()
335        .filter_map(|p| {
336            let u = reqwest::Url::parse(&p.url).ok()?;
337            let path = u.path();
338            (u.host_str() == Some("food.grab.com")
339                && (path.contains("/order/") || path.contains("/track")))
340            .then(|| format!("food.grab.com{path}"))
341        })
342        .collect()
343}
344
345pub fn tracking_tab_warning(tabs: &[String]) -> Option<String> {
346    if tabs.is_empty() {
347        return None;
348    }
349    Some(format!(
350        "{} food.grab.com order-tracking tab(s) may be open in the browser ({}). When the \
351         browser's food session lapses such tabs poll /track, get 401 and call Grab's logout \
352         every ~16s, which logs that browser out. If they are open, close them (grabcli won't \
353         touch other tabs).",
354        tabs.len(),
355        tabs.join(", ")
356    ))
357}
358
359// ------------------------------------------------------------- page model
360
361/// Where the weblogin page is, from [`PROBE_JS`].
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub enum Screen {
364    /// Still loading / spinner.
365    Loading,
366    /// The phone-number form.
367    Phone,
368    /// "Choose a preferred way to sign in" with these challenge types.
369    Options(Vec<String>),
370    /// Waiting for a code; `channel` as shown ("SMS", "WhatsApp", …).
371    Otp {
372        channel: Option<String>,
373    },
374    /// The code was not accepted.
375    OtpRejected,
376    /// Waiting for an in-app approval.
377    Approval,
378    /// Back on food.grab.com: logged in.
379    Done,
380    /// A step-up that needs a human (reCAPTCHA v2, selfie, PIN, …).
381    StepUp(String),
382    /// An error banner (blocked, rate limited, expired, rejected, …).
383    Problem(String),
384    Unknown(String),
385}
386
387/// Read-only probe of the login tab. Returns structure and fixed UI strings
388/// only; the CLI never prints `text`.
389pub const PROBE_JS: &str = r#"(() => {
390  const root = document.getElementById('root');
391  const options = [...document.querySelectorAll('[role=button] img[alt^="Proceed to "]')]
392    .map(i => i.getAttribute('alt').slice('Proceed to '.length));
393  const captcha = [...document.querySelectorAll('iframe')].some(f =>
394    /recaptcha\/(api2|enterprise)\/bframe/.test(f.src || '') && f.getBoundingClientRect().height > 100);
395  return {
396    host: location.host, path: location.pathname, ready: document.readyState,
397    has_phone: !!document.getElementById('Phone'), options, captcha,
398    text: ((root && root.innerText) || (document.body && document.body.innerText) || '').slice(0, 3000),
399  };
400})()"#;
401
402const PROBLEMS: &[&str] = &[
403    "Account temporarily blocked",
404    "temporarily locked",
405    "You've tried too many times",
406    "Try again later",
407    "Too many attempts",
408    "Unable to resend code",
409    "OTP challenge expired",
410    "Request rejected",
411    "QR Code Expired",
412    "Account Not Found",
413    "Oops, something went wrong",
414];
415
416pub fn classify_screen(p: &Value) -> Screen {
417    let s = |k: &str| p.get(k).and_then(Value::as_str).unwrap_or_default();
418    let host = s("host");
419    let path = s("path");
420    let text = s("text");
421    if host == "food.grab.com" && !path.starts_with("/auth/") {
422        return Screen::Done;
423    }
424    if host != "weblogin.grab.com" {
425        return if host.is_empty() || host == "food.grab.com" {
426            Screen::Loading
427        } else {
428            Screen::Unknown(host.to_string())
429        };
430    }
431    if p.get("captcha").and_then(Value::as_bool) == Some(true) {
432        return Screen::StepUp("recaptcha".into());
433    }
434    if let Some(pr) = PROBLEMS.iter().find(|w| text.contains(**w)) {
435        return Screen::Problem((*pr).to_string());
436    }
437    if text.contains("Incorrect code") || text.contains("no longer valid") {
438        return Screen::OtpRejected;
439    }
440    let segs: Vec<&str> = path.split('/').filter(|x| !x.is_empty()).collect();
441    let has = |x: &str| segs.contains(&x);
442    let otp_channel = || {
443        ["SMS", "WhatsApp", "Viber", "Zalo"]
444            .iter()
445            .find(|c| text.contains(&format!("Check your {c}")))
446            .map(|c| (*c).to_string())
447    };
448    if has("optimised-sms-otp") || has("sms-otp") || has("email-otp") {
449        return Screen::Otp {
450            channel: otp_channel(),
451        };
452    }
453    if has("push-v2") || has("push") {
454        return Screen::Approval;
455    }
456    if let Some(seg) = [
457        "recaptcha",
458        "arkose",
459        "web-selfie",
460        "passkey",
461        "password",
462        "pin",
463    ]
464    .iter()
465    .find(|x| has(x))
466    {
467        return Screen::StepUp((*seg).to_string());
468    }
469    let options: Vec<String> = p
470        .get("options")
471        .and_then(Value::as_array)
472        .map(|a| {
473            a.iter()
474                .filter_map(Value::as_str)
475                .map(String::from)
476                .collect()
477        })
478        .unwrap_or_default();
479    if has("challenge-options") || (!options.is_empty() && text.contains("Choose")) {
480        return Screen::Options(options);
481    }
482    // Fallbacks on the UI copy in case the route names change.
483    if text.contains("Approve in mobile app") || text.contains("App push notification sent") {
484        return Screen::Approval;
485    }
486    if text.contains("Check your") || text.contains("Enter verification code") {
487        return Screen::Otp {
488            channel: otp_channel(),
489        };
490    }
491    if p.get("has_phone").and_then(Value::as_bool) == Some(true) {
492        return Screen::Phone;
493    }
494    Screen::Loading
495}
496
497/// Pick the challenge option to click for `channel`, if Grab offers one.
498pub fn pick_option(channel: Channel, offered: &[String]) -> Option<&'static str> {
499    channel
500        .challenge_types()
501        .iter()
502        .copied()
503        .find(|t| offered.iter().any(|o| o == t))
504}
505
506// ------------------------------------------------------------- dry run
507
508/// What `--phone …` would do, without doing it (`--dry-run`).
509pub fn dry_run_plan(
510    phone_masked: &str,
511    channel: Channel,
512    cdp_base: &str,
513    log: &SendLog,
514    now: i64,
515) -> Value {
516    json!({
517        "kind": "dry_run",
518        "dry_run": true,
519        "sent": false,
520        "phone": phone_masked,
521        "channel": channel.as_str(),
522        "cdp": cdp_base,
523        "would_send": channel.describe(),
524        "steps": [
525            "try a silent SSO refresh first (never sends anything); stop if it works",
526            "open food.grab.com/auth/login in a NEW isolated browser context (no shared cookies, other tabs untouched)",
527            "type the phone number, keep \"Remember me on this device for 60 days\" ticked, click Continue",
528            format!("if Grab offers a choice, pick {} (once; never resend or retry)", channel.as_str()),
529            format!("stop and keep the login tab alive for {} min; finish with `grab auth login --otp <code>` or `grab auth login --poll`", PENDING_TTL_SECS / 60),
530        ],
531        "sends_last_hour": log.sends_last_hour(now),
532        "max_sends_per_hour": MAX_SENDS_PER_HOUR,
533        "next_send_allowed_in_secs": log.wait_secs(now),
534    })
535}
536
537// ------------------------------------------------------------ the driver
538
539/// JS: centre of the first element matching `selector` (scrolled into
540/// view), or null.
541fn rect_js(find_expr: &str) -> String {
542    format!(
543        r#"(() => {{ const el = {find_expr}; if (!el) return null;
544  el.scrollIntoView({{block: 'center'}}); const r = el.getBoundingClientRect();
545  if (r.width === 0 || r.height === 0) return null;
546  return {{x: r.left + r.width / 2, y: r.top + r.height / 2, disabled: !!el.disabled}}; }})()"#
547    )
548}
549
550const CONTINUE_BTN: &str = "[...document.querySelectorAll('button.dui-btn-primary, button')].find(b => b.innerText.trim() === 'Continue')";
551const PHONE_INPUT: &str = "document.getElementById('Phone')";
552const REMEMBER_LABEL: &str =
553    "[...document.querySelectorAll('label')].find(l => /Remember me/.test(l.innerText))";
554const REMEMBER_STATE: &str = r#"(() => { const l = [...document.querySelectorAll('label')].find(l => /Remember me/.test(l.innerText));
555  if (!l) return 'absent'; const i = l.querySelector('input[type=checkbox]');
556  return (i && i.checked) || /checkbox-wrapper-checked/.test(l.className) ? 'checked' : 'unchecked'; })()"#;
557/// The OTP input: the first visible, empty-ish text-like input on the page.
558const OTP_INPUT: &str = "[...document.querySelectorAll('#root input')].filter(i => i.id !== 'Phone' && !['checkbox','search','hidden'].includes(i.type) && i.offsetParent !== null).find(i => !i.value) || [...document.querySelectorAll('#root input')].find(i => i.id !== 'Phone' && !['checkbox','search','hidden'].includes(i.type) && i.offsetParent !== null)";
559
560struct Tab {
561    browser: Browser,
562    session: String,
563}
564
565impl Tab {
566    async fn probe(&mut self) -> Result<Screen> {
567        // Keep the CDP event buffer from growing while we poll.
568        self.browser.drain_events(Duration::ZERO).await.ok();
569        match self.browser.eval(&self.session, PROBE_JS).await {
570            Ok(v) => Ok(classify_screen(&v)),
571            // Mid-navigation the execution context can vanish; treat as loading.
572            Err(_) => Ok(Screen::Loading),
573        }
574    }
575
576    /// Poll until `done(screen)` or `timeout`; returns the last screen.
577    async fn wait_for(
578        &mut self,
579        timeout: Duration,
580        done: impl Fn(&Screen) -> bool,
581    ) -> Result<Screen> {
582        let deadline = tokio::time::Instant::now() + timeout;
583        loop {
584            let s = self.probe().await?;
585            if done(&s) || tokio::time::Instant::now() >= deadline {
586                return Ok(s);
587            }
588            tokio::time::sleep(Duration::from_millis(700)).await;
589        }
590    }
591
592    async fn rect(&mut self, find_expr: &str) -> Result<Option<(f64, f64, bool)>> {
593        let v = self
594            .browser
595            .eval(&self.session, &rect_js(find_expr))
596            .await?;
597        Ok(
598            match (
599                v.get("x").and_then(Value::as_f64),
600                v.get("y").and_then(Value::as_f64),
601            ) {
602                (Some(x), Some(y)) => Some((
603                    x,
604                    y,
605                    v.get("disabled").and_then(Value::as_bool).unwrap_or(false),
606                )),
607                _ => None,
608            },
609        )
610    }
611
612    async fn click(&mut self, find_expr: &str, what: &str) -> Result<()> {
613        let (x, y, _) = self
614            .rect(find_expr)
615            .await?
616            .ok_or_else(|| Error::new(ErrorKind::Api, format!("login page: no {what} found")))?;
617        self.browser.click_at(&self.session, x, y).await
618    }
619}
620
621/// Options for [`start`].
622#[derive(Clone, Copy)]
623pub struct StartOpts<'a> {
624    pub cdp_base: &'a str,
625    pub phone: &'a str,
626    pub channel: Channel,
627}
628
629fn page_error(s: &Screen) -> Error {
630    match s {
631        Screen::StepUp(kind) => Error::new(
632            ErrorKind::CaptchaRequired,
633            format!(
634                "Grab asked for an extra check ({kind}) that needs a person. Log in at food.grab.com \
635                 in a normal browser window instead, then run `grab auth import`"
636            ),
637        ),
638        Screen::Problem(p) if p.contains("block") || p.contains("many") || p.contains("later") => {
639            Error::new(
640                ErrorKind::RateLimited,
641                format!("Grab says: {p}. Wait before trying again."),
642            )
643        }
644        Screen::Problem(p) => Error::new(ErrorKind::LoginExpired, format!("Grab says: {p}")),
645        other => Error::new(
646            ErrorKind::Api,
647            format!(
648                "the login page is in an unexpected state ({})",
649                screen_name(other)
650            ),
651        ),
652    }
653}
654
655fn screen_name(s: &Screen) -> &'static str {
656    match s {
657        Screen::Loading => "loading",
658        Screen::Phone => "phone form",
659        Screen::Options(_) => "challenge options",
660        Screen::Otp { .. } => "code entry",
661        Screen::OtpRejected => "code rejected",
662        Screen::Approval => "in-app approval",
663        Screen::Done => "logged in",
664        Screen::StepUp(_) => "step-up",
665        Screen::Problem(_) => "error banner",
666        Screen::Unknown(_) => "unknown",
667    }
668}
669
670/// Best-effort: dispose the isolated context (and its tab and cookies).
671async fn dispose(cdp_base: &str, context: &str) {
672    if let Ok(mut b) = Browser::connect(cdp_base).await {
673        b.dispose_context(context).await.ok();
674    }
675}
676
677/// Start a login and send exactly ONE challenge. The caller has already
678/// decided a send is wanted (`--phone` without `--dry-run`) and that silent
679/// refresh failed.
680pub async fn start(opts: StartOpts<'_>) -> Result<PendingLogin> {
681    let now = jwt::now();
682    let (cc, national) = split_phone(opts.phone)?;
683    let masked = mask_phone(&cc, &national);
684    if let Some(p) = PendingLogin::read()? {
685        if !p.is_expired(now) {
686            return Err(Error::invalid(format!(
687                "a login for {} is already pending ({}, {}s left): finish it with `grab auth login --otp <code>` / `--poll`, or run `grab auth login --cancel`",
688                p.phone_masked,
689                p.kind.as_str(),
690                p.expires_at - now
691            )));
692        }
693        dispose(&p.cdp_base, &p.browser_context_id).await;
694        PendingLogin::clear()?;
695    }
696    let mut log = SendLog::load()?;
697    log.check(now)?;
698
699    let mut browser = Browser::connect(opts.cdp_base).await?;
700    let ctx = browser.create_context().await?;
701    let result = drive_send(browser, &ctx, opts, &cc, &national, &masked, &mut log).await;
702    match result {
703        Ok(p) => Ok(p),
704        Err(e) => {
705            dispose(opts.cdp_base, &ctx).await;
706            Err(e)
707        }
708    }
709}
710
711async fn drive_send(
712    mut browser: Browser,
713    ctx: &str,
714    opts: StartOpts<'_>,
715    cc: &str,
716    national: &str,
717    masked: &str,
718    log: &mut SendLog,
719) -> Result<PendingLogin> {
720    let target = browser.open_tab(ctx, "about:blank").await?;
721    let session = browser.attach(&target).await?;
722    browser.navigate(&session, LOGIN_START_URL).await?;
723    let mut tab = Tab { browser, session };
724
725    let s = tab
726        .wait_for(Duration::from_secs(45), |s| !matches!(s, Screen::Loading))
727        .await?;
728    if s != Screen::Phone {
729        return Err(page_error(&s));
730    }
731    // Let reCAPTCHA / the fingerprint script settle like a person would.
732    tokio::time::sleep(Duration::from_secs(2)).await;
733
734    let shown = tab.probe_text().await?;
735    if !shown.contains(&format!("+{cc}")) {
736        return Err(Error::invalid(format!(
737            "the login page isn't set to +{cc}; only the page's default country is supported. Nothing was sent."
738        )));
739    }
740    if tab
741        .browser
742        .eval(&tab.session, REMEMBER_STATE)
743        .await?
744        .as_str()
745        == Some("unchecked")
746    {
747        tab.click(REMEMBER_LABEL, "remember-me checkbox").await?;
748    }
749    tab.click(PHONE_INPUT, "phone field").await?;
750    tab.browser.type_digits(&tab.session, national).await?;
751    let typed = tab
752        .browser
753        .eval(
754            &tab.session,
755            "(document.getElementById('Phone') || {}).value.replace(/\\D/g, '')",
756        )
757        .await?;
758    if typed.as_str() != Some(national) {
759        return Err(Error::new(
760            ErrorKind::Api,
761            "the phone field didn't take the number as typed; nothing was sent",
762        ));
763    }
764    tokio::time::sleep(Duration::from_millis(600)).await;
765    match tab.rect(CONTINUE_BTN).await? {
766        Some((_, _, false)) => {}
767        _ => {
768            return Err(Error::new(
769                ErrorKind::Api,
770                "the Continue button is missing or disabled; nothing was sent",
771            ));
772        }
773    }
774
775    // ---- Point of no return: from here on a challenge may be sent. Record
776    // the send first so a crash still counts against the limits.
777    log.record(jwt::now());
778    log.save()?;
779    tab.click(CONTINUE_BTN, "Continue button").await?;
780    let mut s = tab
781        .wait_for(Duration::from_secs(40), |s| {
782            !matches!(s, Screen::Phone | Screen::Loading)
783        })
784        .await?;
785    if let Screen::Options(offered) = &s {
786        let Some(kind) = pick_option(opts.channel, offered) else {
787            return Err(Error::invalid(format!(
788                "Grab didn't offer {} for this account (offered: {}). Nothing was sent; the attempt still counts toward the limit.",
789                opts.channel.as_str(),
790                offered.join(", ")
791            )));
792        };
793        let opt = format!(
794            "(() => {{ const i = document.querySelector('[role=button] img[alt=\"Proceed to {kind}\"]'); return i ? i.closest('[role=button]') : null; }})()"
795        );
796        tab.click(&opt, "challenge option").await?;
797        s = tab
798            .wait_for(Duration::from_secs(40), |s| {
799                !matches!(s, Screen::Options(_) | Screen::Loading)
800            })
801            .await?;
802    }
803    let (kind, channel_sent) = match &s {
804        Screen::Otp { channel } => (PendingKind::OtpSent, channel.clone()),
805        Screen::Approval => (PendingKind::ApprovalPending, Some("app".to_string())),
806        Screen::Phone => {
807            return Err(Error::new(
808                ErrorKind::Api,
809                "the login page stayed on the phone form after Continue (maybe a field error). Not retrying.",
810            ));
811        }
812        other => return Err(page_error(other)),
813    };
814    let now = jwt::now();
815    let p = PendingLogin {
816        kind,
817        cdp_base: opts.cdp_base.to_string(),
818        browser_context_id: ctx.to_string(),
819        target_id: target,
820        created_at: now,
821        expires_at: now + PENDING_TTL_SECS,
822        phone_masked: masked.to_string(),
823        channel_requested: opts.channel,
824        channel_sent,
825    };
826    p.save()?;
827    Ok(p)
828}
829
830impl Tab {
831    async fn probe_text(&mut self) -> Result<String> {
832        let v = self.browser.eval(&self.session, PROBE_JS).await?;
833        Ok(v.get("text")
834            .and_then(Value::as_str)
835            .unwrap_or_default()
836            .to_string())
837    }
838}
839
840/// Reattach to the pending login's tab, or clean up and explain.
841async fn reattach(p: &PendingLogin) -> Result<Tab> {
842    if p.is_expired(jwt::now()) {
843        dispose(&p.cdp_base, &p.browser_context_id).await;
844        PendingLogin::clear()?;
845        return Err(Error::new(
846            ErrorKind::LoginExpired,
847            "the pending login expired; start again with `grab auth login --phone <number>` (mind the send limits)",
848        ));
849    }
850    let mut browser = Browser::connect(&p.cdp_base).await?;
851    if !browser.context_exists(&p.browser_context_id).await?
852        || !browser
853            .tab_in_context(&p.browser_context_id, &p.target_id)
854            .await?
855    {
856        PendingLogin::clear()?;
857        return Err(Error::new(
858            ErrorKind::LoginExpired,
859            "the login tab is gone (browser restarted or tab closed); start again with `grab auth login --phone <number>`",
860        ));
861    }
862    let session = browser.attach(&p.target_id).await?;
863    Ok(Tab { browser, session })
864}
865
866/// Poll the context's cookies for a logged-in `gfc_session`; save them as
867/// the session and dispose the context.
868async fn collect_session(tab: &mut Tab, p: &PendingLogin) -> Result<Session> {
869    let deadline = tokio::time::Instant::now() + Duration::from_secs(45);
870    loop {
871        let cookies = tab.browser.context_cookies(&p.browser_context_id).await?;
872        let prev = Session::load_default().unwrap_or_default();
873        let mut s = prev.clone();
874        s.cookies = cookies;
875        if s.gfc_logged_in() {
876            // The isolated context has no saved address/country/language:
877            // keep the previous session's, else take the address from the
878            // browser's normal (default) context. Values stay in memory.
879            s.carry_over_preferences(&prev);
880            if s.cookie(LOCATION_COOKIE).is_none()
881                && let Ok(def) = tab.browser.default_cookies().await
882            {
883                s.replace_named_cookies(LOCATION_COOKIE, &def);
884            }
885            if !tab.browser.user_agent.is_empty() {
886                s.user_agent = Some(tab.browser.user_agent.clone());
887            }
888            s.exported_at = Some(jwt::now() as f64);
889            s.save_default()?;
890            tab.browser
891                .dispose_context(&p.browser_context_id)
892                .await
893                .ok();
894            PendingLogin::clear()?;
895            return Ok(s);
896        }
897        if tokio::time::Instant::now() >= deadline {
898            return Err(Error::new(
899                ErrorKind::Api,
900                "the login page finished but food.grab.com set no logged-in gfc_session; the tab is kept until the pending login expires",
901            ));
902        }
903        tokio::time::sleep(Duration::from_secs(1)).await;
904    }
905}
906
907/// `grab auth login --otp <code>`: type the code into the waiting page.
908pub async fn finish_with_otp(otp: &str) -> Result<Session> {
909    let otp = otp.trim();
910    if !(4..=8).contains(&otp.len()) || !otp.chars().all(|c| c.is_ascii_digit()) {
911        return Err(Error::invalid("the code should be 4-8 digits"));
912    }
913    let p = PendingLogin::read()?.ok_or_else(no_pending)?;
914    if p.kind != PendingKind::OtpSent {
915        return Err(Error::invalid(
916            "this login is waiting for an in-app approval: run `grab auth login --poll`",
917        ));
918    }
919    let mut tab = reattach(&p).await?;
920    match tab.probe().await? {
921        Screen::Otp { .. } | Screen::OtpRejected => {}
922        Screen::Done => return collect_session(&mut tab, &p).await,
923        other => return Err(finish_error(&p, &other).await),
924    }
925    tab.click(OTP_INPUT, "code field").await?;
926    tab.browser.type_digits(&tab.session, otp).await?;
927    // The page submits by itself once the code is complete.
928    let s = tab
929        .wait_for(Duration::from_secs(45), |s| {
930            !matches!(s, Screen::Otp { .. } | Screen::Loading)
931        })
932        .await?;
933    match s {
934        Screen::Done => collect_session(&mut tab, &p).await,
935        Screen::OtpRejected => Err(Error::new(
936            ErrorKind::OtpInvalid,
937            "Grab didn't accept the code; check it and run `grab auth login --otp <code>` again (the page allows only a few tries)",
938        )),
939        Screen::Otp { .. } | Screen::Loading => {
940            // Maybe the redirect is just slow: check the cookies anyway.
941            collect_session(&mut tab, &p).await
942        }
943        other => Err(finish_error(&p, &other).await),
944    }
945}
946
947/// Outcome of [`wait_for_approval`].
948pub enum WaitOutcome {
949    LoggedIn(Session),
950    StillWaiting(PendingLogin),
951}
952
953/// `grab auth login --poll`: wait up to `timeout` for the in-app approval.
954pub async fn wait_for_approval(timeout: Duration) -> Result<WaitOutcome> {
955    let p = PendingLogin::read()?.ok_or_else(no_pending)?;
956    if p.kind != PendingKind::ApprovalPending {
957        return Err(Error::invalid(
958            "this login is waiting for a code: run `grab auth login --otp <code>`",
959        ));
960    }
961    let mut tab = reattach(&p).await?;
962    let left = u64::try_from(p.expires_at - jwt::now()).unwrap_or(0);
963    let timeout = timeout.min(Duration::from_secs(left));
964    let s = tab
965        .wait_for(timeout, |s| {
966            !matches!(s, Screen::Approval | Screen::Loading)
967        })
968        .await?;
969    match s {
970        Screen::Done => Ok(WaitOutcome::LoggedIn(collect_session(&mut tab, &p).await?)),
971        Screen::Approval | Screen::Loading => Ok(WaitOutcome::StillWaiting(p)),
972        other => Err(finish_error(&p, &other).await),
973    }
974}
975
976/// A terminal page state while finishing: clean up and map the error.
977async fn finish_error(p: &PendingLogin, s: &Screen) -> Error {
978    dispose(&p.cdp_base, &p.browser_context_id).await;
979    PendingLogin::clear().ok();
980    page_error(s)
981}
982
983fn no_pending() -> Error {
984    Error::new(
985        ErrorKind::LoginExpired,
986        "no login in progress: start one with `grab auth login --phone <number>`",
987    )
988}
989
990/// `grab auth login --cancel`: dispose the isolated context, forget the
991/// pending login. Send limits are kept.
992pub async fn cancel() -> Result<bool> {
993    let Some(p) = PendingLogin::read()? else {
994        return Ok(false);
995    };
996    dispose(&p.cdp_base, &p.browser_context_id).await;
997    PendingLogin::clear()?;
998    Ok(true)
999}
1000
1001/// Order-tracking tabs in the shared browser (best-effort; empty if the
1002/// browser is unreachable). `/json` can still list a tab for a moment after
1003/// it was closed, so a non-empty answer is re-checked once after a short
1004/// delay and only tabs seen both times are reported.
1005pub async fn shared_browser_tabs(cdp_base: &str) -> Vec<String> {
1006    let first = cdp::pages(cdp_base)
1007        .await
1008        .map(|p| order_tracking_tabs(&p))
1009        .unwrap_or_default();
1010    if first.is_empty() {
1011        return first;
1012    }
1013    tokio::time::sleep(Duration::from_millis(1500)).await;
1014    let second = cdp::pages(cdp_base)
1015        .await
1016        .map(|p| order_tracking_tabs(&p))
1017        .unwrap_or_default();
1018    still_open(&first, &second)
1019}
1020
1021/// Tabs present in both listings.
1022pub fn still_open(first: &[String], second: &[String]) -> Vec<String> {
1023    first
1024        .iter()
1025        .filter(|t| second.contains(t))
1026        .cloned()
1027        .collect()
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033
1034    #[test]
1035    fn splits_and_masks_phone() -> Result<()> {
1036        let (cc, n) = split_phone("+63 900 000 0012")?;
1037        assert_eq!((cc.as_str(), n.as_str()), ("63", "9000000012"));
1038        assert_eq!(mask_phone(&cc, &n), "+63••••••••12");
1039        assert!(!mask_phone(&cc, &n).contains("900"));
1040        let (cc, n) = split_phone("+65-8000-0000")?;
1041        assert_eq!((cc.as_str(), n.as_str()), ("65", "80000000"));
1042        assert!(split_phone("09000000000").is_err());
1043        assert!(split_phone("+1 555 000 0000").is_err());
1044        assert!(split_phone("+63 12").is_err());
1045        assert_eq!(mask_phone("63", "1"), "+631");
1046        Ok(())
1047    }
1048
1049    #[test]
1050    fn channels() -> Result<()> {
1051        assert_eq!(Channel::parse("APP")?, Channel::App);
1052        assert_eq!(Channel::parse("whatsapp")?, Channel::Whatsapp);
1053        assert!(Channel::parse("email").is_err());
1054        let offered = vec![
1055            "PUSH_V2".to_string(),
1056            "CHANNEL_OPTIMIZED_PHONE_OTP".to_string(),
1057        ];
1058        assert_eq!(pick_option(Channel::App, &offered), Some("PUSH_V2"));
1059        assert_eq!(
1060            pick_option(Channel::Sms, &offered),
1061            Some("CHANNEL_OPTIMIZED_PHONE_OTP")
1062        );
1063        assert_eq!(pick_option(Channel::Sms, &["PUSH".to_string()]), None);
1064        Ok(())
1065    }
1066
1067    #[test]
1068    fn cooldown_and_hourly_cap() {
1069        let mut log = SendLog::default();
1070        let t0 = 1_000_000;
1071        assert!(log.check(t0).is_ok());
1072        log.record(t0);
1073        // Cooldown.
1074        assert_eq!(log.wait_secs(t0 + 10), 50);
1075        let e = log.check(t0 + 10).err();
1076        assert_eq!(e.as_ref().map(|e| e.kind), Some(ErrorKind::RateLimited));
1077        assert!(log.check(t0 + 60).is_ok());
1078        log.record(t0 + 60);
1079        log.record(t0 + 200);
1080        // Three in the hour: blocked until the first one ages out.
1081        assert_eq!(log.sends_last_hour(t0 + 300), 3);
1082        assert_eq!(log.wait_secs(t0 + 300), 3600 - 300);
1083        assert!(log.check(t0 + 3599).is_err());
1084        assert!(log.check(t0 + 3600).is_ok());
1085        // Recording prunes entries older than an hour.
1086        log.record(t0 + 3700);
1087        assert_eq!(log.sends.len(), 2);
1088    }
1089
1090    #[test]
1091    fn send_log_roundtrip_and_missing() -> Result<()> {
1092        let dir = tempfile::tempdir()?;
1093        let path = dir.path().join("login-sends.json");
1094        assert!(SendLog::load_from(&path)?.sends.is_empty());
1095        let mut log = SendLog::default();
1096        log.record(42);
1097        log.save_to(&path)?;
1098        assert_eq!(SendLog::load_from(&path)?.sends, vec![42]);
1099        Ok(())
1100    }
1101
1102    #[test]
1103    fn pending_roundtrip_is_private_and_has_no_phone() -> Result<()> {
1104        let dir = tempfile::tempdir()?;
1105        let path = dir.path().join("login-pending.json");
1106        assert!(PendingLogin::read_from(&path)?.is_none());
1107        let p = PendingLogin {
1108            kind: PendingKind::OtpSent,
1109            cdp_base: "http://127.0.0.1:9222".into(),
1110            browser_context_id: "CTX".into(),
1111            target_id: "TAB".into(),
1112            created_at: 100,
1113            expires_at: 100 + PENDING_TTL_SECS,
1114            phone_masked: mask_phone("63", "9000000012"),
1115            channel_requested: Channel::Sms,
1116            channel_sent: Some("SMS".into()),
1117        };
1118        p.save_to(&path)?;
1119        let raw = std::fs::read_to_string(&path)?;
1120        assert!(!raw.contains("9000000012"));
1121        let back = PendingLogin::read_from(&path)?.ok_or_else(no_pending)?;
1122        assert_eq!(back.kind, PendingKind::OtpSent);
1123        assert_eq!(back.browser_context_id, "CTX");
1124        assert!(!back.is_expired(200));
1125        assert!(back.is_expired(100 + PENDING_TTL_SECS));
1126        assert_eq!(back.summary(160)["expires_in_secs"], json!(540));
1127        let j = pending_json(&back, 160);
1128        assert_eq!(j["kind"], json!("otp_sent"));
1129        assert_eq!(j["expires_at"], json!(100 + PENDING_TTL_SECS));
1130        assert_eq!(j["next"], json!("grab auth login --otp <code>"));
1131        let approval = PendingLogin {
1132            kind: PendingKind::ApprovalPending,
1133            ..back
1134        };
1135        assert_eq!(
1136            pending_json(&approval, 160)["next"],
1137            json!("grab auth login --poll")
1138        );
1139        #[cfg(unix)]
1140        {
1141            use std::os::unix::fs::PermissionsExt;
1142            assert_eq!(
1143                std::fs::metadata(&path)?.permissions().mode() & 0o777,
1144                0o600
1145            );
1146        }
1147        Ok(())
1148    }
1149
1150    #[test]
1151    fn dry_run_sends_nothing_and_reports_limits() {
1152        let mut log = SendLog::default();
1153        log.record(1000);
1154        let v = dry_run_plan("+63••••••••12", Channel::App, "http://x:9222", &log, 1030);
1155        assert_eq!(v["kind"], json!("dry_run"));
1156        assert_eq!(v["dry_run"], json!(true));
1157        assert_eq!(v["sent"], json!(false));
1158        assert_eq!(v["phone"], json!("+63••••••••12"));
1159        assert_eq!(v["sends_last_hour"], json!(1));
1160        assert_eq!(v["next_send_allowed_in_secs"], json!(30));
1161        assert!(
1162            v["would_send"]
1163                .as_str()
1164                .unwrap_or_default()
1165                .contains("approval")
1166        );
1167    }
1168
1169    fn probe(host: &str, path: &str, text: &str) -> Value {
1170        json!({"host": host, "path": path, "text": text, "has_phone": false, "options": [], "captcha": false})
1171    }
1172
1173    #[test]
1174    fn classifies_screens() {
1175        let mut phone = probe(
1176            "weblogin.grab.com",
1177            "/passenger/login",
1178            "Sign in to Grab\n+63",
1179        );
1180        phone["has_phone"] = json!(true);
1181        assert_eq!(classify_screen(&phone), Screen::Phone);
1182        assert_eq!(
1183            classify_screen(&probe("weblogin.grab.com", "/passenger/login", "")),
1184            Screen::Loading
1185        );
1186        let mut opts = probe(
1187            "weblogin.grab.com",
1188            "/passenger/challenge-options",
1189            "Choose a preferred way to sign in",
1190        );
1191        opts["options"] = json!(["PUSH_V2", "CHANNEL_OPTIMIZED_PHONE_OTP"]);
1192        assert_eq!(
1193            classify_screen(&opts),
1194            Screen::Options(vec!["PUSH_V2".into(), "CHANNEL_OPTIMIZED_PHONE_OTP".into()])
1195        );
1196        assert_eq!(
1197            classify_screen(&probe(
1198                "weblogin.grab.com",
1199                "/passenger/optimised-sms-otp",
1200                "Check your WhatsApp\nWe've sent a verification code"
1201            )),
1202            Screen::Otp {
1203                channel: Some("WhatsApp".into())
1204            }
1205        );
1206        assert_eq!(
1207            classify_screen(&probe(
1208                "weblogin.grab.com",
1209                "/passenger/push-v2",
1210                "Approve in mobile app"
1211            )),
1212            Screen::Approval
1213        );
1214        assert_eq!(
1215            classify_screen(&probe(
1216                "weblogin.grab.com",
1217                "/passenger/optimised-sms-otp",
1218                "Incorrect code. You can try 2 more times."
1219            )),
1220            Screen::OtpRejected
1221        );
1222        assert!(matches!(
1223            classify_screen(&probe(
1224                "weblogin.grab.com",
1225                "/passenger/x",
1226                "Account temporarily blocked"
1227            )),
1228            Screen::Problem(_)
1229        ));
1230        assert_eq!(
1231            classify_screen(&probe("weblogin.grab.com", "/passenger/web-selfie", "")),
1232            Screen::StepUp("web-selfie".into())
1233        );
1234        let mut cap = probe("weblogin.grab.com", "/passenger/login", "");
1235        cap["captcha"] = json!(true);
1236        assert_eq!(classify_screen(&cap), Screen::StepUp("recaptcha".into()));
1237        assert_eq!(
1238            classify_screen(&probe("food.grab.com", "/ph/en/", "")),
1239            Screen::Done
1240        );
1241        assert_eq!(
1242            classify_screen(&probe("food.grab.com", "/auth/callback", "")),
1243            Screen::Loading
1244        );
1245        // Text fallback if routes are renamed.
1246        assert_eq!(
1247            classify_screen(&probe(
1248                "weblogin.grab.com",
1249                "/passenger/new",
1250                "Check your SMS"
1251            )),
1252            Screen::Otp {
1253                channel: Some("SMS".into())
1254            }
1255        );
1256    }
1257
1258    #[test]
1259    fn finds_tracking_tabs() {
1260        let pages = vec![
1261            PageInfo {
1262                id: "1".into(),
1263                url: "https://food.grab.com/ph/en/order/001-ABC".into(),
1264            },
1265            PageInfo {
1266                id: "2".into(),
1267                url: "https://food.grab.com/ph/en/restaurants".into(),
1268            },
1269            PageInfo {
1270                id: "3".into(),
1271                url: "https://example.com/order/1".into(),
1272            },
1273        ];
1274        let tabs = order_tracking_tabs(&pages);
1275        assert_eq!(tabs, vec!["food.grab.com/ph/en/order/001-ABC".to_string()]);
1276        assert!(tracking_tab_warning(&tabs).is_some_and(|w| w.contains("logout")));
1277        assert!(tracking_tab_warning(&[]).is_none());
1278    }
1279
1280    #[test]
1281    fn stale_tabs_are_dropped() {
1282        let a = "food.grab.com/ph/en/order/A".to_string();
1283        let b = "food.grab.com/ph/en/order/B".to_string();
1284        assert_eq!(
1285            still_open(&[a.clone(), b.clone()], std::slice::from_ref(&b)),
1286            vec![b.clone()]
1287        );
1288        assert!(still_open(&[a], &[]).is_empty());
1289    }
1290}