Files
Kigi-CLI/crates/codegen/kigi-fsnotify/tests/integration.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

371 lines
12 KiB
Rust

//! Integration tests using the public API only. Each test exercises the
//! real OS watcher against a `tempfile`-rooted fake git repo.
//!
//! These can be flaky on some CI runners where FS events aren't reliably
//! delivered (matches the existing pattern in `watcher.rs` integration
//! tests). Marked `#[ignore]` for now; run locally with
//! `cargo test --test integration -- --ignored`.
use std::fs;
use std::time::Duration;
use kigi_fsnotify::{FsConfig, FsEvent, FsEventKind, FsEventSource};
use serial_test::serial;
use tempfile::TempDir;
use tokio::sync::broadcast;
use tokio::time::timeout;
fn fake_git_repo() -> TempDir {
let temp = TempDir::new().unwrap();
let git_dir = temp.path().join(".git");
fs::create_dir(&git_dir).unwrap();
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
fs::create_dir_all(git_dir.join("objects")).unwrap();
fs::create_dir_all(git_dir.join("refs")).unwrap();
temp
}
fn fake_sl_repo() -> TempDir {
let temp = TempDir::new().unwrap();
let sl_dir = temp.path().join(".sl");
fs::create_dir(&sl_dir).unwrap();
fs::write(sl_dir.join("dirstate"), sl_dirstate(0x11)).unwrap();
temp
}
/// `.sl/dirstate` = p1(20) ‖ p2(NULL_ID, 20) ‖ "\ntreestate\n"; only the
/// leading p1 (working-copy parent) is read by the source.
fn sl_dirstate(p1_byte: u8) -> Vec<u8> {
let mut v = vec![p1_byte; 20];
v.extend_from_slice(&[0u8; 20]);
v.extend_from_slice(b"\ntreestate\n");
v
}
async fn recv_until(
rx: &mut broadcast::Receiver<FsEvent>,
pred: impl Fn(&FsEvent) -> bool,
) -> FsEvent {
loop {
match rx.recv().await {
Ok(e) if pred(&e) => return e,
Ok(_) => continue,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => panic!("channel closed"),
}
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "flaky in CI; FS events not reliably delivered"]
async fn source_emits_files_changed() {
let temp = fake_git_repo();
let source = FsEventSource::start(temp.path().to_path_buf(), {
let mut c = FsConfig::default();
c.debounce_ms = 50;
c
})
.unwrap();
let mut rx = source.subscribe();
tokio::time::sleep(Duration::from_millis(200)).await;
fs::write(temp.path().join("hello.txt"), "world").unwrap();
let event = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| matches!(e, FsEvent::FilesChanged { .. })),
)
.await
.unwrap();
match event {
FsEvent::FilesChanged { kind, paths } => {
assert!(matches!(kind, FsEventKind::Created | FsEventKind::Modified));
assert!(paths.iter().any(|p| p.ends_with("hello.txt")));
}
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "flaky in CI; FS events not reliably delivered"]
async fn source_emits_git_op_started_and_completed_no_head_change() {
let temp = fake_git_repo();
let source = FsEventSource::start(temp.path().to_path_buf(), {
let mut c = FsConfig::default();
c.debounce_ms = 50;
c
})
.unwrap();
let mut rx = source.subscribe();
tokio::time::sleep(Duration::from_millis(200)).await;
let lock = temp.path().join(".git/index.lock");
fs::write(&lock, "").unwrap();
let _ = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| matches!(e, FsEvent::GitOperationStarted)),
)
.await
.unwrap();
fs::remove_file(&lock).unwrap();
let completed = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| {
matches!(e, FsEvent::GitOperationCompleted { .. })
}),
)
.await
.unwrap();
match completed {
FsEvent::GitOperationCompleted { head_changed } => assert!(!head_changed),
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "flaky in CI; FS events not reliably delivered"]
async fn source_emits_completed_with_head_change_on_branch_switch() {
let temp = fake_git_repo();
let source = FsEventSource::start(temp.path().to_path_buf(), {
let mut c = FsConfig::default();
c.debounce_ms = 50;
c
})
.unwrap();
let mut rx = source.subscribe();
tokio::time::sleep(Duration::from_millis(200)).await;
let lock = temp.path().join(".git/index.lock");
fs::write(&lock, "").unwrap();
let _ = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| matches!(e, FsEvent::GitOperationStarted)),
)
.await
.unwrap();
fs::write(temp.path().join(".git/HEAD"), "ref: refs/heads/feature\n").unwrap();
fs::remove_file(&lock).unwrap();
let event = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| {
matches!(e, FsEvent::GitOperationCompleted { .. })
}),
)
.await
.unwrap();
match event {
FsEvent::GitOperationCompleted { head_changed } => assert!(head_changed),
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "flaky in CI; FS events not reliably delivered"]
async fn source_emits_sl_op_started_and_completed_no_head_change() {
let temp = fake_sl_repo();
let source = FsEventSource::start(temp.path().to_path_buf(), {
let mut c = FsConfig::default();
c.debounce_ms = 50;
c
})
.unwrap();
let mut rx = source.subscribe();
tokio::time::sleep(Duration::from_millis(200)).await;
let wlock = temp.path().join(".sl/wlock");
fs::write(&wlock, "").unwrap();
let _ = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| matches!(e, FsEvent::GitOperationStarted)),
)
.await
.unwrap();
// Release without moving p1 (e.g. a dirty-treestate `sl status`).
fs::remove_file(&wlock).unwrap();
let completed = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| {
matches!(e, FsEvent::GitOperationCompleted { .. })
}),
)
.await
.unwrap();
match completed {
FsEvent::GitOperationCompleted { head_changed } => assert!(!head_changed),
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "flaky in CI; FS events not reliably delivered"]
async fn source_emits_completed_with_head_change_on_sl_goto() {
let temp = fake_sl_repo();
let source = FsEventSource::start(temp.path().to_path_buf(), {
let mut c = FsConfig::default();
c.debounce_ms = 50;
c
})
.unwrap();
let mut rx = source.subscribe();
tokio::time::sleep(Duration::from_millis(200)).await;
let wlock = temp.path().join(".sl/wlock");
fs::write(&wlock, "").unwrap();
let _ = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| matches!(e, FsEvent::GitOperationStarted)),
)
.await
.unwrap();
// Move the working-copy parent (p1) before releasing wlock, then release.
// `read_head` reads the new p1 on demand when the wlock-removal is processed.
fs::write(temp.path().join(".sl/dirstate"), sl_dirstate(0x22)).unwrap();
fs::remove_file(&wlock).unwrap();
let event = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| {
matches!(e, FsEvent::GitOperationCompleted { .. })
}),
)
.await
.unwrap();
match event {
FsEvent::GitOperationCompleted { head_changed } => assert!(head_changed),
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn shared_dedupes_by_directory() {
use std::sync::Arc;
let temp = TempDir::new().unwrap();
let path = temp.path().to_path_buf();
// First call creates the watcher; subsequent calls for the same canonical
// directory hand back clones of the *same* source rather than opening a
// new OS watch. Skip gracefully where the OS denies watches (CI limits).
let Ok(a) = kigi_fsnotify::shared(path.clone(), FsConfig::default()) else {
eprintln!("skipping: OS watcher unavailable (resource limit?)");
return;
};
let before = kigi_fsnotify::stats();
let b = kigi_fsnotify::shared(path.clone(), FsConfig::default()).unwrap();
assert!(Arc::ptr_eq(&a, &b), "same dir must share one watcher");
assert_eq!(
Arc::strong_count(&a),
2,
"second shared() must clone the existing source, not create a new one"
);
// The reuse must be counted as a cache hit (no new OS watcher created).
let after = kigi_fsnotify::stats();
assert_eq!(
after.reused_total - before.reused_total,
1,
"reuse must increment reused_total"
);
assert_eq!(
after.created_total, before.created_total,
"reuse must not create a new watcher"
);
assert!(after.live_watchers >= 1, "the shared watcher must be live");
// A different directory gets its own independent watcher (a real miss).
let other = TempDir::new().unwrap();
let c = kigi_fsnotify::shared(other.path().to_path_buf(), FsConfig::default()).unwrap();
assert!(!Arc::ptr_eq(&a, &c), "different dirs must not share");
assert_eq!(
kigi_fsnotify::stats().created_total - after.created_total,
1,
"a new directory must create a new watcher"
);
// Once the last sharer drops, the registry entry is reclaimed and a later
// request rebuilds a fresh source (exercises the recreate-after-drop path).
drop(a);
drop(b);
let d = kigi_fsnotify::shared(path, FsConfig::default()).unwrap();
assert_eq!(
Arc::strong_count(&d),
1,
"after all sharers drop, shared() must build a new source"
);
}
/// Runnable measurement: simulates many sessions/subagents all watching the
/// same working directory and prints how many OS watchers were saved.
///
/// ```bash
/// cargo test -p kigi-fsnotify --test integration \
/// shared_watcher_scaling_demo -- --nocapture
/// ```
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn shared_watcher_scaling_demo() {
const SESSIONS: usize = 50;
let temp = TempDir::new().unwrap();
let path = temp.path().to_path_buf();
let before = kigi_fsnotify::stats();
// Hold every handle alive, mirroring N concurrent sessions on one cwd.
let mut handles = Vec::with_capacity(SESSIONS);
for _ in 0..SESSIONS {
let Ok(src) = kigi_fsnotify::shared(path.clone(), FsConfig::default()) else {
eprintln!("skipping: OS watcher unavailable (resource limit?)");
return;
};
handles.push(src);
}
let after = kigi_fsnotify::stats();
let created = after.created_total - before.created_total;
let reused = after.reused_total - before.reused_total;
println!(
"shared watcher scaling: {SESSIONS} sessions on one cwd -> \
created={created}, reused(saved)={reused}, live_watchers={}",
after.live_watchers
);
println!(
" before sharing this needed {SESSIONS} OS watchers; after sharing it needs {created}."
);
// One real OS watch for the whole fleet; the rest are cache hits.
assert_eq!(created, 1, "all sessions on one cwd share a single watcher");
assert_eq!(reused, (SESSIONS - 1) as u64);
assert!(after.live_watchers >= 1);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "flaky in CI; FS events not reliably delivered"]
async fn source_works_in_non_git_workspace() {
let temp = TempDir::new().unwrap();
let source = FsEventSource::start(temp.path().to_path_buf(), {
let mut c = FsConfig::default();
c.debounce_ms = 50;
c
})
.unwrap();
let mut rx = source.subscribe();
tokio::time::sleep(Duration::from_millis(200)).await;
fs::write(temp.path().join("hi.txt"), "x").unwrap();
let event = timeout(
Duration::from_secs(2),
recv_until(&mut rx, |e| matches!(e, FsEvent::FilesChanged { .. })),
)
.await
.unwrap();
assert!(matches!(event, FsEvent::FilesChanged { .. }));
}