aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/tests/chrome_context.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/tests/chrome_context.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/tests/chrome_context.rs')
-rw-r--r--src/client/shell/tests/chrome_context.rs339
1 files changed, 339 insertions, 0 deletions
diff --git a/src/client/shell/tests/chrome_context.rs b/src/client/shell/tests/chrome_context.rs
new file mode 100644
index 00000000..eeb272c2
--- /dev/null
+++ b/src/client/shell/tests/chrome_context.rs
@@ -0,0 +1,339 @@
+use super::*;
+
+#[test]
+fn tab_overflow_controls_scroll_the_client_owned_tab_bar() {
+ let mut snapshot = snapshot();
+ snapshot.tabs.extend((2..=8).map(|number| ClientShellTab {
+ tab_id: format!("tab_{number}"),
+ workspace_id: "ws_1".into(),
+ number,
+ label: number.to_string(),
+ custom_label: false,
+ zoomed: false,
+ focused: false,
+ agent_status: AgentStatus::Idle,
+ }));
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot));
+ state.set_pane_surface(surface());
+ state.compose(80, 20).expect("overflow tab bar");
+
+ assert!(state.hits.tab_scroll_right.width > 0);
+ let scroll_right = state.hits.tab_scroll_right;
+ let outcome =
+ state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: scroll_right.x + 1,
+ row: scroll_right.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(outcome.repaint);
+ assert_eq!(state.tab_scroll, 1);
+
+ let mut update = state.snapshot.as_deref().expect("snapshot").clone();
+ update.focused_tab_id = Some("tab_8".into());
+ for tab in &mut update.tabs {
+ tab.focused = tab.tab_id == "tab_8";
+ }
+ state.set_snapshot(Box::new(update));
+ state.compose(80, 20).expect("focused overflow tab");
+ assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_8"));
+
+ state.compose(300, 20).expect("tabs without overflow");
+ assert_eq!(state.tab_scroll, 0);
+ assert_eq!(state.hits.tabs.len(), 8);
+ state.compose(80, 20).expect("focused tab after narrowing");
+ assert!(state.hits.tabs.iter().any(|(_, tab_id)| tab_id == "tab_8"));
+}
+
+#[test]
+fn client_owned_sidebar_dividers_resize_live() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.compose(106, 30).expect("expanded sidebar");
+ let workspace_body = state.hits.workspace_body;
+ let needless_scroll =
+ state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::ScrollDown,
+ column: workspace_body.x,
+ row: workspace_body.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert_eq!(state.hits.workspace_max_scroll, 0);
+ assert_eq!(state.workspace_scroll, 0);
+ assert!(!needless_scroll.repaint);
+ let width_divider = state.hits.sidebar_divider;
+ state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: width_divider.x,
+ row: width_divider.y + 2,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ let resize =
+ state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Drag(MouseButton::Left),
+ column: 31,
+ row: width_divider.y + 2,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert_eq!(state.sidebar_width, 32);
+ assert!(state.sidebar_width_manual);
+ assert!(resize.repaint);
+ assert!(resize.resize);
+ state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Up(MouseButton::Left),
+ column: 31,
+ row: width_divider.y + 2,
+ modifiers: KeyModifiers::empty(),
+ })]);
+
+ state.set_pane_surface(surface());
+ state.compose(106, 30).expect("resized sidebar");
+ let section_divider = state.hits.sidebar_section_divider;
+ state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: section_divider.x + 2,
+ row: section_divider.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ let split = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Drag(MouseButton::Left),
+ column: section_divider.x + 2,
+ row: 20,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(state.sidebar_section_split > 0.6);
+ assert!(split.repaint);
+ assert!(!split.resize);
+}
+
+#[test]
+fn context_menus_capture_stable_targets_and_route_actions() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.compose(106, 20).expect("composed frame");
+
+ let workspace = state.hits.workspaces[0].rect;
+ let open_workspace_menu =
+ state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Right),
+ column: workspace.x + 2,
+ row: workspace.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(open_workspace_menu.actions.is_empty());
+ assert!(matches!(
+ state.overlay,
+ Some(ClientShellOverlay::ContextMenu(ClientContextMenuOverlay {
+ target: ClientContextMenuTarget::Workspace { ref workspace_id, .. },
+ ..
+ })) if workspace_id == "ws_1"
+ ));
+ let workspace_items = match state.overlay.as_ref() {
+ Some(ClientShellOverlay::ContextMenu(menu)) => menu.items(),
+ _ => panic!("workspace context menu"),
+ };
+ assert!(workspace_items
+ .iter()
+ .any(|item| item.action == ClientContextMenuAction::NewWorktree));
+ state.compose(106, 20).expect("workspace context menu");
+ let rename = state.hits.context_menu_rows[0].0;
+ state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: rename.x + 1,
+ row: rename.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(matches!(
+ state.overlay,
+ Some(ClientShellOverlay::Rename(ClientRenameOverlay {
+ target: ClientRenameTarget::Workspace { ref workspace_id },
+ ..
+ })) if workspace_id == "ws_1"
+ ));
+
+ state.overlay = None;
+ state.compose(106, 20).expect("composed frame");
+ let pane = state.hits.panes[0].rect;
+ state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Right),
+ column: pane.x + 1,
+ row: pane.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ state.compose(106, 20).expect("pane context menu");
+ let split_index = match state.overlay.as_ref() {
+ Some(ClientShellOverlay::ContextMenu(menu)) => menu
+ .items()
+ .iter()
+ .position(|item| item.action == ClientContextMenuAction::SplitRight)
+ .expect("split right item"),
+ _ => panic!("pane context menu"),
+ };
+ let split = state.hits.context_menu_rows[split_index].0;
+ let outcome =
+ state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: split.x + 1,
+ row: split.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else {
+ panic!("pane split context action should use endpoint API");
+ };
+ assert!(matches!(
+ &request.method,
+ crate::api::schema::Method::PaneSplit(params)
+ if params.target_pane_id.as_deref() == Some("pane_1")
+ && params.direction == crate::api::schema::SplitDirection::Right
+ ));
+}
+
+#[test]
+fn global_menu_opens_from_sidebar_and_routes_client_actions() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.compose(106, 30).expect("shell frame");
+ let launcher = state.hits.global_launcher;
+ assert_ne!(launcher, Rect::default());
+
+ let open = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: launcher.x,
+ row: launcher.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(open.repaint);
+ let menu = state.compose(106, 30).expect("global menu");
+ let text = menu
+ .cells
+ .chunks(menu.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+ assert!(text.contains("settings"));
+ assert!(text.contains("keybinds"));
+ assert!(text.contains("reload config"));
+ assert!(text.contains("detach"));
+
+ let keybinds = state.hits.global_menu_rows[1].0;
+ let help = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: keybinds.x,
+ row: keybinds.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(help.actions.is_empty());
+ assert!(matches!(state.overlay, Some(ClientShellOverlay::Help(_))));
+
+ state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay {
+ highlighted: 3,
+ }));
+ let detach = state.handle_input_bytes(b"\r");
+ assert!(detach.detach);
+ assert!(state.overlay.is_none());
+}
+
+#[test]
+fn new_tab_overlay_owns_text_cursor_and_submits_public_api_request() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ let mut open = ClientShellInput::default();
+ state.record_binding(
+ crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab),
+ &mut open,
+ );
+ assert!(open.actions.is_empty());
+ let frame = state.compose(106, 20).expect("new tab overlay");
+ let text = frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+ assert!(text.contains("new tab"));
+ assert!(text.contains("save"));
+ let restored = frame.to_ratatui_buffer().expect("overlay frame");
+ assert!(!restored
+ .cell((26, 7))
+ .expect("overlay title cell")
+ .modifier
+ .contains(Modifier::DIM));
+ assert!(frame.cursor.as_ref().is_some_and(|cursor| cursor.visible));
+
+ assert!(state.handle_input_bytes(b"logs").actions.is_empty());
+ let create = state.handle_input_bytes(b"\r");
+ let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else {
+ panic!("new tab save should use endpoint API");
+ };
+ assert!(matches!(
+ &request.method,
+ crate::api::schema::Method::TabCreate(params)
+ if params.workspace_id.as_deref() == Some("ws_1")
+ && params.label.as_deref() == Some("logs")
+ ));
+ assert!(state.overlay.is_none());
+}
+
+#[test]
+fn close_confirmation_error_becomes_client_owned_overlay_and_stable_group_close() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ let mut close = ClientShellInput::default();
+ state.record_binding(
+ crate::input::KeybindMatch::Action(crate::input::KeybindAction::ClosePane),
+ &mut close,
+ );
+ let [ClientShellAction::Endpoint { request, .. }] = &close.actions[..] else {
+ panic!("pane close should use endpoint API");
+ };
+ let request_id = request.id.clone();
+ assert!(
+ state
+ .handle_endpoint_result(
+ "boot-1",
+ &request_id,
+ Err(ClientShellEndpointError {
+ code: Some("confirmation_required".into()),
+ message: "confirmation required".into(),
+ }),
+ )
+ .0
+ );
+ let frame = state.compose(106, 20).expect("confirmation overlay");
+ let text = frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+ assert!(text.contains("Close workspace?"));
+ assert!(text.contains("1 pane"));
+
+ let confirm = state.handle_input_bytes(b"\r");
+ let [ClientShellAction::Endpoint { request, .. }] = &confirm.actions[..] else {
+ panic!("confirmation should use endpoint API");
+ };
+ assert!(matches!(
+ &request.method,
+ crate::api::schema::Method::WorkspaceClose(params)
+ if params.workspace_id == "ws_1" && params.close_group
+ ));
+}