diff --git a/README.en.md b/README.en.md index 59e6c1f..13c87c5 100644 --- a/README.en.md +++ b/README.en.md @@ -149,7 +149,7 @@ browser-cli close [--json] browser-cli close --all [--json] browser-cli --version -browser-cli page [-p ] [--next] [--prev] [--fresh] [--json] [--verbose] +browser-cli page [-p ] [--next] [--prev] [--all] [--settle ] [--fresh] [--json] [--verbose] browser-cli click [-p ] [--new-session] [--fresh] [--quiet] [--json] browser-cli type [-p ] [--fresh] [--quiet] [--json] browser-cli search [--fresh] [--json] [--verbose] @@ -188,11 +188,14 @@ browser-cli teardown [--browser chrome|firefox] - `t1`, `t2`, ... are IDs for truncated text blocks, readable with `text`; both `t1` and `1` are accepted - `b1`, `b2`, ... are IDs for paginated list/table blocks, readable with `block`; both `b1` and `1` are accepted - `--next` / `--prev` paginate relative to the current scroll position +- `--all` auto-scrolls through every logical page, waits for lazy content to render, and aggregates the result into one reading view; the output is always `current=1 total=1` +- `--settle ` only applies to `page --all` and controls the fixed delay after each scroll; defaults to `500` - `--fresh` bypasses the Relay cache and fetches a fresh browser snapshot - `--version` prints the version injected at build time, or `unknown` if not set - `open` returns the page structure by default; use `--quiet` for session info only, `--wait 0` to skip the post-open stability wait - `open` / `close` / `list` / `search` / `wait` / `plugin` / `view` all support `--json` - `page` / `search` / `block` / `view` support `--verbose`; this mainly matters for JSON mode, where the default `--json` output is compact and `--verbose` returns full detail +- `page --all` is optimized for full-page reading; if you need to click or type afterwards, run a normal `page` / `page -p N` again to get current actionable IDs - The `` for `click` / `type` accepts a prefixed ID (`e1`), a bare number (`1` maps to `e1`), or a text query matching button text, link text, or input placeholder/value - `click` / `type` output the updated full page XML by default; use `--quiet` for a success summary, `--json` for a structured response - `wait` returns the latest page on success; use `--quiet` in automation pipelines when you only need the success/timeout result diff --git a/README.md b/README.md index 4c48bf3..391941b 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ browser-cli close [--json] browser-cli close --all [--json] browser-cli --version -browser-cli page [-p <页码>] [--next] [--prev] [--fresh] [--json] [--verbose] +browser-cli page [-p <页码>] [--next] [--prev] [--all] [--settle <毫秒>] [--fresh] [--json] [--verbose] browser-cli click <目标> [-p <页码>] [--new-session] [--fresh] [--quiet] [--json] browser-cli type <目标> <文本> [-p <页码>] [--fresh] [--quiet] [--json] browser-cli search <关键词> [--fresh] [--json] [--verbose] @@ -188,11 +188,14 @@ browser-cli teardown [--browser chrome|firefox] - `t1`, `t2`, ... — 被截断的长文本 ID,用 `text` 命令查看完整内容;参数支持 `t1` 或 `1` - `b1`, `b2`, ... — 被分页的长 `list` / `table` 块 ID,用 `block` 命令继续查看后续分页;参数支持 `b1` 或 `1` - `--next` / `--prev` 按当前滚动位置相对翻页 +- `--all` 会按逻辑页自动滚动、等待懒加载渲染并聚合成单页阅读视图;输出固定为 `current=1 total=1` +- `--settle <毫秒>` 仅配合 `page --all` 使用,控制每次滚动后的固定等待时间,默认 `500` - `--fresh` 跳过缓存,强制从浏览器获取最新快照 - `--version` 显示构建时注入的版本号;若未注入则显示 `unknown` - `open` 默认会在创建会话后直接输出当前页;用 `--quiet` 只看会话信息,用 `--wait 0` 可跳过打开后的稳定等待 - `open` / `close` / `list` / `search` / `wait` / `plugin` / `view` 全部支持 `--json` - `page` / `search` / `block` / `view` 支持 `--verbose`:主要用于拿完整 JSON 细节;不带时,`--json` 默认返回更紧凑的数据 +- `page --all` 更偏全量阅读视图,方便 AI/用户一次拿完整内容;如果后续要点击或输入,建议回到普通 `page` / `page -p N` 重新获取当前页元素 ID - `click` / `type` 的 `<目标>` 既可以是带前缀 ID(如 `e1`)、数字 ID(如 `1` 对应 `e1`),也可以是当前页交互元素的文本查询;查询会匹配按钮文本、链接文本、输入框 placeholder/value 等 - `click` / `type` 默认会输出更新后的整页 XML;可用 `--quiet` 只看成功结果,用 `--json` 获取结构化摘要 - `wait` 默认等待页面稳定并返回最新页面;`--for <文本>` 会轮询最新快照,直到页面里出现匹配该文本的元素 diff --git a/SKILL.md b/SKILL.md index b8baa42..b9470e7 100644 --- a/SKILL.md +++ b/SKILL.md @@ -15,6 +15,7 @@ description: "Drives a real browser session via browser-cli: open stateful sessi - Prefer `--json` when another tool or agent will consume the result. - Use `--verbose` with `page`, `search`, `block`, or `view` when you need full JSON detail instead of the default compact form. - Use `--fresh` when the cache may be stale or the page is highly dynamic. +- Use `page --all` when you need a full reading pass over a long or lazy-loaded page; add `--settle ` if the site needs more time after each auto-scroll. - `open`, `click`, `type`, and `wait` return the current page by default; use `--quiet` when you only need a compact success result. - Use `click --new-session` only for link elements with `href`. It opens the destination in a new session and keeps the source session unchanged. @@ -94,6 +95,8 @@ browser-cli page s123 browser-cli page s123 -p 2 browser-cli page s123 --next browser-cli page s123 --prev +browser-cli page s123 --all +browser-cli page s123 --all --settle 1000 --json browser-cli page s123 --fresh ``` @@ -175,6 +178,7 @@ browser-cli type s123 3 "hello world" --quiet - Long text may be truncated in-page and assigned `t1`, `t2`, etc.; `text` accepts either `t1` or `1`. - Large lists or tables may be exposed as block IDs `b1`, `b2`, etc.; `block` accepts either `b1` or `1`. - Pagination is viewport-based. `page -p N` reads a logical page slice without scrolling the browser manually. +- `page --all` auto-scrolls and merges all logical pages into a single reading-oriented result. Refresh with a normal `page` before acting on IDs from a live page. ## Plugin usage diff --git a/src/cli/commands.rs b/src/cli/commands.rs index ddd9a63..2f8b56a 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1,8 +1,11 @@ use anyhow::{Result, bail}; use serde::Serialize; use serde_json::json; +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::time::sleep; use url::Url; use crate::page::structure::{ @@ -15,6 +18,8 @@ use crate::transport::client::send_request; const NATIVE_HOST_NAME: &str = "com.browser_cli.relay"; const CHROME_EXTENSION_PLACEHOLDER: &str = "REPLACE_WITH_EXTENSION_ID"; const FIREFOX_EXTENSION_PLACEHOLDER: &str = "4fu@browser-cli"; +const DEFAULT_PAGE_ALL_SETTLE_MS: u64 = 500; +const NODE_IDENTITY_KEY_SEPARATOR: &str = "\u{1f}"; #[derive(Debug, Clone, Serialize)] struct ActionOutput { @@ -211,31 +216,42 @@ pub async fn page( page_num: Option, next: bool, prev: bool, + all: bool, + settle_ms: Option, fresh: bool, json_mode: bool, verbose: bool, ) -> Result<()> { - let action = if fresh { - actions::GET_PAGE_FRESH - } else { - actions::GET_PAGE - }; - let snapshot = fetch_snapshot(session_id, action).await?; - let resolved_page = resolve_requested_page(&snapshot, page_num, next, prev); - let snapshot = if let Some(target_page) = resolved_page { - send_ok(Request::new( - actions::SCROLL, - json!({ - "session_id": session_id, - "top": scroll_top_for_page(&snapshot, target_page), - }), - )) - .await?; - fetch_snapshot(session_id, actions::GET_PAGE_FRESH).await? + let page_data = if all { + fetch_all_pages( + session_id, + fresh, + settle_ms.unwrap_or(DEFAULT_PAGE_ALL_SETTLE_MS), + ) + .await? } else { - snapshot + let action = if fresh { + actions::GET_PAGE_FRESH + } else { + actions::GET_PAGE + }; + let snapshot = fetch_snapshot(session_id, action).await?; + let resolved_page = resolve_requested_page(&snapshot, page_num, next, prev); + let snapshot = if let Some(target_page) = resolved_page { + send_ok(Request::new( + actions::SCROLL, + json!({ + "session_id": session_id, + "top": scroll_top_for_page(&snapshot, target_page), + }), + )) + .await?; + fetch_snapshot(session_id, actions::GET_PAGE_FRESH).await? + } else { + snapshot + }; + parse_page_from_snapshot(&snapshot, resolved_page)? }; - let page_data = parse_page_from_snapshot(&snapshot, resolved_page)?; println!("{}", crate::cli::output::format_page(&page_data, json_mode, verbose)); Ok(()) } @@ -1147,6 +1163,344 @@ async fn send_ok(req: Request) -> Result { send_request(&req).await?.into_result() } +async fn scroll_session_to(session_id: &str, top: f64) -> Result<()> { + send_ok(Request::new( + actions::SCROLL, + json!({ + "session_id": session_id, + "top": top, + }), + )) + .await?; + Ok(()) +} + +async fn fetch_all_pages(session_id: &str, fresh: bool, settle_ms: u64) -> Result { + let initial_action = if fresh { + actions::GET_PAGE_FRESH + } else { + actions::GET_PAGE + }; + let initial_snapshot = fetch_snapshot(session_id, initial_action).await?; + let original_scroll_top = initial_snapshot.scroll.top; + + let result = fetch_all_pages_from_snapshot(session_id, initial_snapshot, settle_ms).await; + let restore_result = scroll_session_to(session_id, original_scroll_top).await; + + match (result, restore_result) { + (Ok(page), Ok(())) => Ok(page), + (Ok(_), Err(err)) => { + Err(err.context("captured full page but failed to restore scroll position")) + } + (Err(err), Ok(())) => Err(err), + (Err(err), Err(restore_err)) => Err(err.context(format!( + "also failed to restore scroll position: {restore_err}" + ))), + } +} + +async fn fetch_all_pages_from_snapshot( + session_id: &str, + mut snapshot: crate::protocol::messages::RawSnapshot, + settle_ms: u64, +) -> Result { + let mut pages = Vec::new(); + let mut target_page = 1; + + loop { + let total_pages = total_pages_for_snapshot(&snapshot); + if target_page > total_pages { + break; + } + + if total_pages > 1 { + eprintln!("Scrolling {target_page}/{total_pages}..."); + } + scroll_session_to(session_id, scroll_top_for_page(&snapshot, target_page)).await?; + sleep(Duration::from_millis(settle_ms)).await; + snapshot = fetch_snapshot(session_id, actions::GET_PAGE_FRESH).await?; + pages.push(parse_page_from_snapshot(&snapshot, Some(target_page))?); + target_page += 1; + } + + Ok(aggregate_pages(pages)) +} + +fn aggregate_pages(pages: Vec) -> PageData { + if pages.is_empty() { + return PageData { + url: String::new(), + title: String::new(), + current_page: 1, + total_pages: 1, + truncated: false, + shown: 0, + total: 0, + nodes: Vec::new(), + element_refs: Default::default(), + full_texts: Default::default(), + full_blocks: Default::default(), + }; + } + + let mut pages = pages.into_iter(); + let first_page = pages + .next() + .expect("pages iterator should not be empty after checking"); + let mut aggregated_nodes = first_page.nodes; + let mut seen_keys: HashSet = aggregated_nodes.iter().map(node_identity_key).collect(); + + for page in pages { + append_page_nodes(&mut aggregated_nodes, &mut seen_keys, page.nodes); + } + + reassign_page_node_ids(&mut aggregated_nodes); + let total = count_page_nodes(&aggregated_nodes); + + PageData { + url: first_page.url, + title: first_page.title, + current_page: 1, + total_pages: 1, + truncated: false, + shown: total, + total, + nodes: aggregated_nodes, + element_refs: Default::default(), + full_texts: Default::default(), + full_blocks: Default::default(), + } +} + +fn append_page_nodes( + aggregated: &mut Vec, + seen_keys: &mut HashSet, + incoming: Vec, +) { + if aggregated.is_empty() { + for node in incoming { + seen_keys.insert(node_identity_key(&node)); + aggregated.push(node); + } + return; + } + + let mut skipping_prefix_duplicates = true; + + for node in incoming { + let key = node_identity_key(&node); + if skipping_prefix_duplicates && seen_keys.contains(&key) { + continue; + } + + skipping_prefix_duplicates = false; + seen_keys.insert(key); + aggregated.push(node); + } +} + +fn node_identity_key(node: &Node) -> String { + match node { + Node::Container { + tag, + role, + class_name, + children, + } => format!( + "container|{tag}|{}|{}|{}", + role.as_deref().unwrap_or_default(), + class_name.as_deref().unwrap_or_default(), + child_identity_keys(children) + ), + Node::Text { text, .. } => format!("text|{text}"), + Node::Heading { level, text } => format!("heading|{level}|{text}"), + Node::Link { + text, + href, + class_name, + .. + } => format!( + "link|{text}|{}|{}", + href.as_deref().unwrap_or_default(), + class_name.as_deref().unwrap_or_default() + ), + Node::Button { + text, class_name, .. + } => format!( + "button|{text}|{}", + class_name.as_deref().unwrap_or_default() + ), + Node::Input { + input_type, + placeholder, + value, + disabled, + .. + } => format!( + "input|{input_type}|{}|{}|{disabled}", + placeholder.as_deref().unwrap_or_default(), + value.as_deref().unwrap_or_default() + ), + Node::Checkbox { text, checked, .. } => format!("checkbox|{text}|{checked}"), + Node::Radio { + text, + name, + selected, + .. + } => format!( + "radio|{text}|{}|{selected}", + name.as_deref().unwrap_or_default() + ), + Node::Select { + text, + selected, + disabled, + .. + } => format!( + "select|{text}|{}|{disabled}", + selected.as_deref().unwrap_or_default() + ), + Node::Textarea { + text, + placeholder, + disabled, + .. + } => format!( + "textarea|{text}|{}|{disabled}", + placeholder.as_deref().unwrap_or_default() + ), + Node::List { + truncated, + shown, + total_items, + current_page, + total_pages, + children, + .. + } => format!( + "list|{truncated}|{shown}|{total_items}|{current_page}|{total_pages}|{}", + child_identity_keys(children) + ), + Node::Item { + class_name, + children, + } => format!( + "item|{}|{}", + class_name.as_deref().unwrap_or_default(), + child_identity_keys(children) + ), + Node::Table { + truncated, + shown, + total_items, + current_page, + total_pages, + children, + .. + } => format!( + "table|{truncated}|{shown}|{total_items}|{current_page}|{total_pages}|{}", + child_identity_keys(children) + ), + Node::Row { children } => format!("row|{}", child_identity_keys(children)), + Node::Cell { children } => format!("cell|{}", child_identity_keys(children)), + Node::Media { + tag, + media_state, + current_time, + duration, + muted, + resolution, + .. + } => format!( + "media|{tag}|{media_state}|{current_time}|{}|{muted}|{}", + duration.map(|value| value.to_string()).unwrap_or_default(), + resolution.as_deref().unwrap_or_default() + ), + } +} + +fn child_identity_keys(children: &[Node]) -> String { + children + .iter() + .map(node_identity_key) + .collect::>() + .join(NODE_IDENTITY_KEY_SEPARATOR) +} + +fn reassign_page_node_ids(nodes: &mut [Node]) { + let mut next_element_id = 1; + let mut next_text_id = 1; + let mut next_block_id = 1; + for node in nodes { + reassign_node_ids( + node, + &mut next_element_id, + &mut next_text_id, + &mut next_block_id, + ); + } +} + +fn reassign_node_ids( + node: &mut Node, + next_element_id: &mut u32, + next_text_id: &mut u32, + next_block_id: &mut u32, +) { + match node { + Node::Container { children, .. } + | Node::Item { children, .. } + | Node::Row { children } + | Node::Cell { children } => { + for child in children { + reassign_node_ids(child, next_element_id, next_text_id, next_block_id); + } + } + Node::Text { id, .. } => { + if id.is_some() { + *id = Some(format!("t{}", *next_text_id)); + *next_text_id += 1; + } + } + Node::Heading { .. } => {} + Node::Link { id, .. } + | Node::Button { id, .. } + | Node::Input { id, .. } + | Node::Checkbox { id, .. } + | Node::Radio { id, .. } + | Node::Select { id, .. } + | Node::Textarea { id, .. } + | Node::Media { id, .. } => { + *id = format!("e{}", *next_element_id); + *next_element_id += 1; + } + Node::List { id, children, .. } | Node::Table { id, children, .. } => { + if id.is_some() { + *id = Some(format!("b{}", *next_block_id)); + *next_block_id += 1; + } + for child in children { + reassign_node_ids(child, next_element_id, next_text_id, next_block_id); + } + } + } +} + +fn count_page_nodes(nodes: &[Node]) -> usize { + nodes + .iter() + .map(|node| match node { + Node::Container { children, .. } + | Node::List { children, .. } + | Node::Item { children, .. } + | Node::Table { children, .. } + | Node::Row { children } + | Node::Cell { children } => 1 + count_page_nodes(children), + _ => 1, + }) + .sum() +} + async fn fetch_action_page( session_id: &str, page_num: Option, @@ -1589,6 +1943,155 @@ mod tests { assert_eq!(scroll_top_for_page(&snapshot, 9), 700.0); } + #[test] + fn aggregate_pages_reindexes_ids_and_resets_pagination() { + let aggregated = aggregate_pages(vec![ + PageData { + url: "https://example.com".into(), + title: "Example".into(), + current_page: 1, + total_pages: 3, + truncated: false, + shown: 2, + total: 2, + nodes: vec![ + Node::Link { + id: "e1".into(), + text: "Alpha".into(), + href: Some("/alpha".into()), + class_name: None, + }, + Node::Text { + id: Some("t1".into()), + text: "Long intro".into(), + }, + ], + element_refs: Default::default(), + full_texts: Default::default(), + full_blocks: Default::default(), + }, + PageData { + url: "https://example.com".into(), + title: "Example".into(), + current_page: 2, + total_pages: 3, + truncated: false, + shown: 2, + total: 2, + nodes: vec![Node::List { + id: Some("b1".into()), + truncated: false, + shown: 1, + total_items: 1, + current_page: 1, + total_pages: 1, + children: vec![Node::Item { + class_name: None, + children: vec![Node::Button { + id: "e1".into(), + text: "Beta".into(), + class_name: None, + }], + }], + }], + element_refs: Default::default(), + full_texts: Default::default(), + full_blocks: Default::default(), + }, + ]); + + assert_eq!(aggregated.current_page, 1); + assert_eq!(aggregated.total_pages, 1); + assert!(!aggregated.truncated); + assert_eq!(aggregated.shown, aggregated.total); + let nodes = serde_json::to_value(&aggregated.nodes).unwrap(); + assert_eq!(nodes[0]["type"], "link"); + assert_eq!(nodes[0]["id"], "e1"); + assert_eq!(nodes[1]["type"], "text"); + assert_eq!(nodes[1]["id"], "t1"); + assert_eq!(nodes[2]["type"], "list"); + assert_eq!(nodes[2]["id"], "b1"); + assert_eq!(nodes[2]["children"][0]["children"][0]["type"], "button"); + assert_eq!(nodes[2]["children"][0]["children"][0]["id"], "e2"); + } + + #[test] + fn aggregate_pages_skips_repeated_leading_nodes_from_later_pages() { + let sticky_header = Node::Container { + tag: "header".into(), + role: None, + class_name: Some("sticky".into()), + children: vec![Node::Link { + id: "e1".into(), + text: "Docs".into(), + href: Some("/docs".into()), + class_name: None, + }], + }; + + let aggregated = aggregate_pages(vec![ + PageData { + url: "https://example.com".into(), + title: "Example".into(), + current_page: 1, + total_pages: 2, + truncated: false, + shown: 2, + total: 2, + nodes: vec![ + sticky_header.clone(), + Node::Text { + id: None, + text: "Page one".into(), + }, + ], + element_refs: Default::default(), + full_texts: Default::default(), + full_blocks: Default::default(), + }, + PageData { + url: "https://example.com".into(), + title: "Example".into(), + current_page: 2, + total_pages: 2, + truncated: false, + shown: 2, + total: 2, + nodes: vec![ + Node::Container { + tag: "header".into(), + role: None, + class_name: Some("sticky".into()), + children: vec![Node::Link { + id: "e9".into(), + text: "Docs".into(), + href: Some("/docs".into()), + class_name: None, + }], + }, + Node::Text { + id: None, + text: "Page two".into(), + }, + ], + element_refs: Default::default(), + full_texts: Default::default(), + full_blocks: Default::default(), + }, + ]); + + assert_eq!(aggregated.nodes.len(), 3); + let nodes = serde_json::to_value(&aggregated.nodes).unwrap(); + assert_eq!( + serde_json::to_value(&sticky_header).unwrap()["children"][0]["text"], + nodes[0]["children"][0]["text"] + ); + assert_eq!(nodes[1]["type"], "text"); + assert_eq!(nodes[1]["text"], "Page one"); + assert_eq!(nodes[2]["type"], "text"); + assert_eq!(nodes[2]["text"], "Page two"); + } + #[test] fn resolve_element_target_accepts_prefixed_id() { let page = PageData { diff --git a/src/main.rs b/src/main.rs index bfc9d11..4a2ea80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -113,14 +113,20 @@ enum Command { /// Session ID session_id: String, /// Page number for paginated content - #[arg(short, long)] + #[arg(short, long, conflicts_with = "all")] page: Option, /// Go to next page relative to current scroll position - #[arg(long, conflicts_with_all = ["page", "prev"])] + #[arg(long, conflicts_with_all = ["page", "prev", "all"])] next: bool, /// Go to previous page relative to current scroll position - #[arg(long, conflicts_with_all = ["page", "next"])] + #[arg(long, conflicts_with_all = ["page", "next", "all"])] prev: bool, + /// Capture and aggregate all logical pages into a single reading view + #[arg(long, conflicts_with_all = ["page", "next", "prev"])] + all: bool, + /// Delay after each scroll before fetching the next page snapshot (milliseconds; only with --all) + #[arg(long, requires = "all")] + settle: Option, /// Bypass cache and fetch a fresh snapshot from the browser #[arg(long)] fresh: bool, @@ -403,10 +409,17 @@ async fn main() -> anyhow::Result<()> { page, next, prev, + all, + settle, fresh, json, verbose, - } => cli::commands::page(session_id, page, next, prev, fresh, json, verbose).await?, + } => { + cli::commands::page( + session_id, page, next, prev, all, settle, fresh, json, verbose, + ) + .await? + } Command::Click { ref session_id, ref target, @@ -485,7 +498,17 @@ async fn main() -> anyhow::Result<()> { json, verbose, } => { - cli::commands::block(session_id, block_id, source_page, page, all, fresh, json, verbose).await? + cli::commands::block( + session_id, + block_id, + source_page, + page, + all, + fresh, + json, + verbose, + ) + .await? } Command::View { ref session_id, @@ -501,7 +524,10 @@ async fn main() -> anyhow::Result<()> { full_page, quality, json, - } => cli::commands::screenshot(session_id, output.as_deref(), full_page, quality, json).await?, + } => { + cli::commands::screenshot(session_id, output.as_deref(), full_page, quality, json) + .await? + } Command::Download { ref session_id, ref target, @@ -544,6 +570,8 @@ fn should_run_as_native_host(args: &[String]) -> bool { mod tests { use super::build_info; use super::should_run_as_native_host; + use super::{Cli, Command}; + use clap::Parser; #[test] fn native_host_detection_matches_browser_args() { @@ -570,4 +598,30 @@ mod tests { "v1.2.3 (abc1234)" ); } + + #[test] + fn page_all_accepts_settle_flag() { + let cli = + Cli::try_parse_from(["browser-cli", "page", "s1", "--all", "--settle", "750"]).unwrap(); + + match cli.command { + Command::Page { all, settle, .. } => { + assert!(all); + assert_eq!(settle, Some(750)); + } + _ => panic!("unexpected command"), + } + } + + #[test] + fn page_all_conflicts_with_explicit_page_navigation_flags() { + assert!(Cli::try_parse_from(["browser-cli", "page", "s1", "--all", "--next"]).is_err()); + assert!(Cli::try_parse_from(["browser-cli", "page", "s1", "--all", "--prev"]).is_err()); + assert!(Cli::try_parse_from(["browser-cli", "page", "s1", "--all", "-p", "2"]).is_err()); + } + + #[test] + fn page_settle_requires_all() { + assert!(Cli::try_parse_from(["browser-cli", "page", "s1", "--settle", "500"]).is_err()); + } }