Files
Kigi-CLI/crates/codegen/kigi-ratatui-inline/src/tests.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

418 lines
14 KiB
Rust

use std::{
collections::VecDeque,
io::{self, Write},
};
use ratatui::layout::{Rect, Size};
use crate::common::TerminalLike;
/// Mock terminal for testing
#[derive(Debug, Clone)]
pub struct MockTerminal {
pub size: Size,
pub viewport_area: Rect,
pub clear_count: usize,
pub viewport_updates: Vec<Rect>,
pub writer: MockWriter,
}
/// Mock writer that captures all output
#[derive(Debug, Clone)]
pub struct MockWriter {
pub buffer: Vec<u8>,
pub flush_count: usize,
pub commands: VecDeque<String>,
}
impl Write for MockWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.buffer.extend_from_slice(buf);
// Parse and store readable command representation
if let Ok(s) = std::str::from_utf8(buf) {
self.commands.push_back(s.to_string());
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.flush_count += 1;
Ok(())
}
}
impl MockTerminal {
pub fn new(width: u16, height: u16, viewport_height: u16) -> Self {
let viewport_y = height - viewport_height;
Self {
size: Size { width, height },
viewport_area: Rect::new(0, viewport_y, width, viewport_height),
clear_count: 0,
viewport_updates: Vec::new(),
writer: MockWriter {
buffer: Vec::new(),
flush_count: 0,
commands: VecDeque::new(),
},
}
}
}
impl TerminalLike for MockTerminal {
type Writer = MockWriter;
fn size(&self) -> io::Result<Size> {
Ok(self.size)
}
fn viewport_area(&self) -> Rect {
self.viewport_area
}
fn clear(&mut self) -> io::Result<()> {
self.clear_count += 1;
Ok(())
}
fn set_viewport_area(&mut self, area: Rect) {
self.viewport_updates.push(area);
self.viewport_area = area;
}
fn writer_mut(&mut self) -> &mut Self::Writer {
&mut self.writer
}
fn reset_back_buffer(&mut self) {
// Mock implementation - just track that it was called
self.clear_count += 1;
}
}
/// Tests for the diffed OSC 8 hyperlink layer (`set_frame_links` /
/// `flush_with_links`).
mod links {
use std::io::{self, Write};
use ratatui::backend::{Backend, WindowSize};
use ratatui::buffer::Cell;
use ratatui::layout::{Position, Rect, Size};
use ratatui::style::Style;
use ratatui::{TerminalOptions, Viewport};
use crate::{LinkSpan, Terminal};
/// Backend that records the raw byte stream and renders each drawn cell as
/// its bare symbol, so tests can assert on OSC 8 sequences interleaved with
/// cell content without depending on crossterm's exact SGR output.
#[derive(Default)]
struct RecordingBackend {
buf: Vec<u8>,
/// Total lines passed to `append_lines` (used by the
/// `set_viewport_height` grow-path test).
appended_lines: u16,
}
impl Write for RecordingBackend {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
self.buf.extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Backend for RecordingBackend {
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
for (_x, _y, cell) in content {
self.buf.extend_from_slice(cell.symbol().as_bytes());
}
Ok(())
}
fn hide_cursor(&mut self) -> io::Result<()> {
Ok(())
}
fn show_cursor(&mut self) -> io::Result<()> {
Ok(())
}
fn get_cursor_position(&mut self) -> io::Result<Position> {
Ok(Position::ORIGIN)
}
fn set_cursor_position<P: Into<Position>>(&mut self, _position: P) -> io::Result<()> {
Ok(())
}
fn clear(&mut self) -> io::Result<()> {
Ok(())
}
fn clear_region(&mut self, _clear_type: ratatui::backend::ClearType) -> io::Result<()> {
Ok(())
}
fn append_lines(&mut self, n: u16) -> io::Result<()> {
self.appended_lines += n;
Ok(())
}
fn size(&self) -> io::Result<Size> {
Ok(Size::new(80, 24))
}
fn window_size(&mut self) -> io::Result<WindowSize> {
Ok(WindowSize {
columns_rows: Size::new(80, 24),
pixels: Size::new(0, 0),
})
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn term(w: u16, h: u16) -> Terminal<RecordingBackend> {
Terminal::with_options(
RecordingBackend::default(),
TerminalOptions {
viewport: Viewport::Fixed(Rect::new(0, 0, w, h)),
},
)
.unwrap()
}
fn span(col_start: u16, col_end: u16, url: &str, id: Option<u32>) -> LinkSpan {
LinkSpan {
row: 0,
col_start,
col_end,
url: url.into(),
id,
}
}
/// Render `text` at (0,0), set `spans`, flush, and return the bytes emitted
/// during this single frame.
fn frame(t: &mut Terminal<RecordingBackend>, text: &str, spans: &[LinkSpan]) -> String {
t.backend_mut().buf.clear();
{
let mut f = t.get_frame();
f.buffer_mut().set_string(0, 0, text, Style::default());
}
t.set_frame_links(spans);
t.flush_with_links().unwrap();
t.swap_buffers();
String::from_utf8(t.backend().buf.clone()).unwrap()
}
#[test]
fn emits_osc8_around_linked_cells() {
let mut t = term(20, 3);
let out = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
assert!(
out.contains("\x1b]8;;https://x.ai\x07"),
"missing open: {out:?}"
);
assert!(out.contains("AB"));
assert!(out.contains("\x1b]8;;\x07"), "missing close: {out:?}");
}
#[test]
fn no_link_emits_no_osc8() {
let mut t = term(20, 3);
let out = frame(&mut t, "AB", &[]);
assert!(!out.contains("\x1b]8;"), "unexpected OSC8: {out:?}");
}
#[test]
fn grow_viewport_scrolls_committed_lines_into_history() {
// A small inline viewport near the bottom of the screen, grown to full
// height, must scroll the rows it will cover up into native scrollback
// (append_lines) instead of overwriting them. Regression guard for the
// previously-commented-out scroll_up in set_viewport_height's grow path
// (the overlay host depends on this in minimal mode).
let mut t = Terminal::with_options(
RecordingBackend::default(),
TerminalOptions {
viewport: Viewport::Inline(3),
},
)
.unwrap();
// Pin the 3-row viewport near the bottom of the 24-row screen.
t.set_viewport_area(Rect::new(0, 21, 80, 3));
let before = t.backend().appended_lines;
// Grow to full height: overflow = (21 + 24) - 24 = 21 rows must scroll up.
t.set_viewport_height(24).unwrap();
let scrolled = t.backend().appended_lines - before;
assert!(
scrolled >= 21,
"expected >= 21 lines scrolled into history, got {scrolled}"
);
}
/// Regression: `set_viewport_height` must judge grow-vs-shrink against the
/// live `viewport_area.height`, not the stored `Viewport::Inline(height)`.
///
/// Minimal mode resizes the viewport out-of-band via `set_viewport_area`
/// (its content-anchored commit path shrinks the region before
/// `insert_before`), which leaves the stored `Inline` height STALE. If the
/// next `set_viewport_height` compared against that stale (larger) height, a
/// genuine grow would be misread as a shrink: the grow-time `scroll_up`
/// would be skipped and the viewport's top would not move up, so the taller
/// viewport would run off the bottom of the screen (dropdown items rendered
/// off-screen — the "empty dropdown over a full screen" bug).
#[test]
fn grow_after_out_of_band_area_shrink_still_scrolls() {
let mut t = Terminal::with_options(
RecordingBackend::default(),
TerminalOptions {
// Stored Inline height starts tall (mimics a streaming turn that
// grew the viewport to near full screen).
viewport: Viewport::Inline(21),
},
)
.unwrap();
// Out-of-band shrink to a 3-row viewport pinned at the bottom of the
// 24-row screen — as the commit path does. This does NOT update the
// stored Inline height (still 21), creating the drift.
t.set_viewport_area(Rect::new(0, 21, 80, 3));
let before = t.backend().appended_lines;
// Grow to 10 rows. Against the real height (3) this is a GROW that
// overflows the bottom by (21 + 10) - 24 = 7 rows, which must scroll up.
// Against the stale stored height (21) it would look like a shrink and
// scroll nothing.
t.set_viewport_height(10).unwrap();
let scrolled = t.backend().appended_lines - before;
assert!(
scrolled >= 7,
"grow after an out-of-band area shrink must scroll the covered rows \
into history (expected >= 7, got {scrolled})"
);
// The viewport top moved up so the whole 10-row region fits on screen.
let area = t.viewport_area();
assert_eq!(area.height, 10, "height should be the requested 10");
assert!(
area.y + area.height <= 24,
"viewport must fit on screen, got y={} h={}",
area.y,
area.height
);
}
#[test]
fn link_removed_next_frame_rewrites_cells_without_osc8() {
let mut t = term(20, 3);
let _ = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
// Same glyphs, but the link is gone: the cells must be rewritten (so the
// terminal's hyperlink clears) and carry no OSC 8. This is the `/new`
// regression — clearing is driven purely by the diff.
let out = frame(&mut t, "AB", &[]);
assert!(out.contains("AB"), "cells should be redrawn: {out:?}");
assert!(!out.contains("\x1b]8;"), "stale OSC8 leaked: {out:?}");
}
#[test]
fn unchanged_link_and_content_emits_nothing() {
let mut t = term(20, 3);
let _ = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
// Identical glyphs AND identical link → empty diff → no output at all.
let out = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
assert!(out.is_empty(), "expected empty diff, got: {out:?}");
}
#[test]
fn retargeted_link_rewrites_cells() {
let mut t = term(20, 3);
let _ = frame(&mut t, "AB", &[span(0, 2, "https://a", None)]);
let out = frame(&mut t, "AB", &[span(0, 2, "https://b", None)]);
assert!(
out.contains("\x1b]8;;https://b\x07"),
"new url not emitted: {out:?}"
);
}
#[test]
fn emit_id_param_included() {
let mut t = term(20, 3);
let out = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", Some(7))]);
assert!(
out.contains("\x1b]8;id=7;https://x.ai\x07"),
"id param missing: {out:?}"
);
}
#[test]
fn url_control_chars_sanitized() {
let mut t = term(20, 3);
let out = frame(&mut t, "AB", &[span(0, 2, "https://x\x07\x1b/y", None)]);
assert!(
out.contains("\x1b]8;;https://x/y\x07"),
"url not sanitized: {out:?}"
);
}
#[test]
fn distinct_links_split_into_separate_runs() {
let mut t = term(20, 3);
// "AxB": A→a, gap x (no link), B→b.
let out = frame(
&mut t,
"AxB",
&[span(0, 1, "https://a", None), span(2, 3, "https://b", None)],
);
// Each link wraps exactly its own cell; the gap is not wrapped.
assert!(
out.contains("\x1b]8;;https://a\x07A\x1b]8;;\x07"),
"a-run: {out:?}"
);
assert!(
out.contains("\x1b]8;;https://b\x07B\x1b]8;;\x07"),
"b-run: {out:?}"
);
}
#[test]
fn wide_char_under_link_wraps_lead_cell_only() {
let mut t = term(20, 3);
// A width-2 char occupies two cells; only the lead cell is drawn, and
// the OSC 8 wraps it.
let out = frame(&mut t, "世", &[span(0, 2, "https://x.ai", None)]);
assert!(
out.contains("\x1b]8;;https://x.ai\x07\x1b]8;;\x07"),
"wide-char run: {out:?}"
);
}
#[test]
fn nonzero_origin_viewport_maps_links() {
// The screen→cell mapping subtracts the viewport offset; verify a link
// at an absolute (row, col) inside a non-origin viewport wraps the right
// cells (regression guard for `(y - area.y)` / `(x - area.x)`).
let area = Rect::new(2, 5, 20, 4);
let mut t = Terminal::with_options(
RecordingBackend::default(),
TerminalOptions {
viewport: Viewport::Fixed(area),
},
)
.unwrap();
{
let mut f = t.get_frame();
f.buffer_mut().set_string(2, 5, "AB", Style::default());
}
t.set_frame_links(&[LinkSpan {
row: 5,
col_start: 2,
col_end: 4,
url: "https://x.ai".into(),
id: None,
}]);
t.flush_with_links().unwrap();
let out = String::from_utf8(t.backend().buf.clone()).unwrap();
assert!(
out.contains("\x1b]8;;https://x.ai\x07AB\x1b]8;;\x07"),
"non-origin mapping: {out:?}"
);
}
}