Files
buzz/desktop/src-tauri/src/commands/agent_discovery/install_capture_tests.rs
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

438 lines
15 KiB
Rust

use super::*;
/// Feed `chunks` through a capture in order.
fn capture_of(chunks: &[&[u8]]) -> Capture {
let capture = Capture::new();
for chunk in chunks {
capture.push(chunk);
}
capture
}
/// Render what the UI would show for a stream of `chunks`.
fn ui(chunks: &[&[u8]]) -> String {
capture_of(chunks).ui()
}
// ── bounded capture ──────────────────────────────────────────────────────────
/// Output within the cap is passed through byte-for-byte — no marker, no loss.
#[test]
fn test_capture_leaves_short_output_untouched() {
let short = "a".repeat(1536);
assert_eq!(ui(&[short.as_bytes()]), short);
}
/// Over the cap, both ends survive and the middle is replaced by a marker
/// naming the omitted byte count — the head keeps the command's opening
/// context and the tail keeps the error that usually trails.
#[test]
fn test_capture_over_cap_keeps_head_and_tail_with_marker() {
let input = format!(
"{}{}{}",
"H".repeat(512),
"M".repeat(4000),
"T".repeat(1024)
);
let out = ui(&[input.as_bytes()]);
assert!(out.starts_with(&"H".repeat(512)));
assert!(out.ends_with(&"T".repeat(1024)));
assert!(
out.contains("... (4000 bytes omitted) ..."),
"marker must name the omitted byte count, got: {out}"
);
}
/// The rendered result depends only on the byte stream, not on how the reads
/// happened to split it — a real drain sees arbitrary chunk sizes.
#[test]
fn test_capture_is_independent_of_chunk_boundaries() {
let input = "x".repeat(9000);
let one_shot = ui(&[input.as_bytes()]);
let chunked: Vec<&[u8]> = input.as_bytes().chunks(7).collect();
assert_eq!(ui(&chunked), one_shot);
}
/// Truncation must not split a multi-byte character. Both cut points land
/// mid-codepoint here; the partial bytes are dropped rather than decoded into
/// replacement chars.
#[test]
fn test_capture_does_not_split_multibyte_characters() {
// "é" is 2 bytes, so every candidate cut index lands mid-character.
let input = "é".repeat(4000);
let out = ui(&[input.as_bytes()]);
assert!(out.contains("bytes omitted"), "input must exceed the cap");
assert!(!out.contains('\u{fffd}'), "no replacement chars: {out}");
}
/// Memory stays flat regardless of how much the installer prints: the rendered
/// UI result of a 4MiB stream is no larger than that of a 6KiB one.
#[test]
fn test_capture_of_huge_output_stays_bounded() {
let chunk = vec![b'z'; 8192];
let capture = Capture::new();
for _ in 0..512 {
capture.push(&chunk);
}
let out = capture.ui();
assert!(
out.len() < 2048,
"4MiB of output must render bounded, got {} bytes",
out.len()
);
assert!(out.contains("bytes omitted"));
}
// ── the log view is separately bounded ───────────────────────────────────────
/// The log view holds output the UI view had to cut. A toast is capped for
/// readability; the log file's budget is disk, and "Full log: {path}" has to
/// point at more than the toast already showed.
#[test]
fn test_log_view_keeps_output_the_ui_view_truncates() {
let input = format!("start{}end", "m".repeat(64 * 1024));
let capture = capture_of(&[input.as_bytes()]);
assert!(
capture.ui().contains("bytes omitted"),
"64KiB must exceed the UI cap"
);
assert_eq!(
capture.log(),
input,
"the same output must be complete in the log view"
);
}
/// Even the log view is bounded — a runaway installer cannot fill the disk —
/// and when it does cut, the record says so inline at the cap rather than
/// implying completeness.
#[test]
fn test_log_view_is_bounded_and_marks_its_cap() {
let head = "H".repeat(128 * 1024);
let middle = "M".repeat(5000);
let tail = "T".repeat(128 * 1024);
let input = format!("{head}{middle}{tail}");
let capture = capture_of(&[input.as_bytes()]);
let out = capture.log();
assert!(
out.len() < 300 * 1024,
"output past the log cap must render bounded, got {} bytes",
out.len()
);
assert!(out.starts_with(&head), "the log head must survive intact");
assert!(out.ends_with(&tail), "the log tail must survive intact");
assert!(
out.contains("... [5000 bytes omitted at cap] ..."),
"a cut log record must name the cap inline, got the middle: {}",
&out[128 * 1024..(128 * 1024 + 64).min(out.len())]
);
}
/// The two views mark their cuts differently on purpose: the toast reads as
/// prose, the log record reads as a machine-scannable annotation.
#[test]
fn test_ui_and_log_views_use_their_own_cap_markers() {
let input = "x".repeat(300 * 1024);
let capture = capture_of(&[input.as_bytes()]);
assert!(
capture.ui().contains("bytes omitted) ..."),
"the UI marker reads as prose: {}",
capture.ui()
);
assert!(capture.log().contains("bytes omitted at cap] ..."));
}
// ── line observation ─────────────────────────────────────────────────────────
/// Collect the lines a drain over `chunks` reports.
fn observed_lines(chunks: &[&[u8]]) -> Vec<String> {
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let observer: LineObserver = {
let seen = Arc::clone(&seen);
Arc::new(move |line: &str| seen.lock().unwrap().push(line.to_string()))
};
let bytes: Vec<u8> = chunks.concat();
drain_into(bytes.as_slice(), &Capture::new(), Some(&observer));
let observed = seen.lock().unwrap().clone();
observed
}
/// The observer sees complete lines, reassembled across the read boundaries
/// that split them — a live output line must never show half a word.
#[test]
fn test_observer_reassembles_lines_split_across_reads() {
let lines = observed_lines(&[b"downloa", b"ding 40%\nunpack", b"ing\n"]);
assert_eq!(lines, vec!["downloading 40%", "unpacking"]);
}
/// A trailing line with no newline is never reported: it may still be growing,
/// and showing a half-line as if complete is worse than showing the previous
/// one.
#[test]
fn test_observer_withholds_a_line_that_has_no_newline_yet() {
let lines = observed_lines(&[b"complete\n", b"still-writing"]);
assert_eq!(lines, vec!["complete"]);
}
/// Blank lines carry nothing to display; progress output is full of them.
#[test]
fn test_observer_skips_blank_lines() {
let lines = observed_lines(&[b"a\n\n \nb\n"]);
assert_eq!(lines, vec!["a", "b"]);
}
/// A pathological line with no newline must not grow the buffer without bound.
#[test]
fn test_observer_caps_a_pathologically_long_line() {
let huge = "x".repeat(100_000);
let lines = observed_lines(&[huge.as_bytes(), b"\n"]);
assert_eq!(lines.len(), 1);
assert!(
lines[0].len() <= LineSplitter::MAX_LINE,
"line must be capped, got {} bytes",
lines[0].len()
);
}
/// A drain with no observer still captures — the log and UI views do not
/// depend on anyone watching.
#[test]
fn test_drain_captures_without_an_observer() {
let capture = Capture::new();
drain_into(b"hello\n".as_slice(), &capture, None);
assert_eq!(capture.ui(), "hello\n");
}
// ── throttle ─────────────────────────────────────────────────────────────────
/// The first line goes out immediately, and one arriving inside the window is
/// *held* rather than dropped: it becomes the pending line, so the newest output
/// survives the rate limit instead of vanishing.
#[test]
fn test_throttle_emits_the_first_line_and_holds_the_next_in_window() {
let throttle = Throttle::new(Duration::from_millis(250));
let start = Instant::now();
assert_eq!(throttle.offer("first", start), Some("first".to_string()));
assert_eq!(
throttle.offer("second", start + Duration::from_millis(100)),
None
);
assert_eq!(
throttle.take_pending(),
Some("second".to_string()),
"the line inside the window must be retained, not dropped"
);
}
/// A burst inside one window collapses to its newest line: the display shows a
/// single line, so an older held line has no value once a newer one exists.
#[test]
fn test_throttle_keeps_only_the_newest_held_line() {
let throttle = Throttle::new(Duration::from_millis(250));
let start = Instant::now();
throttle.offer("emitted", start);
throttle.offer("held-then-superseded", start + Duration::from_millis(50));
throttle.offer("newest", start + Duration::from_millis(100));
assert_eq!(throttle.take_pending(), Some("newest".to_string()));
}
/// Once the window passes, emission resumes and nothing is left pending — the
/// emitted line *is* the newest, so holding it too would emit it twice.
#[test]
fn test_throttle_emits_again_after_the_window_and_clears_the_held_line() {
let throttle = Throttle::new(Duration::from_millis(250));
let start = Instant::now();
throttle.offer("first", start);
throttle.offer("held", start + Duration::from_millis(50));
assert_eq!(
throttle.offer("later", start + Duration::from_millis(300)),
Some("later".to_string())
);
assert_eq!(
throttle.take_pending(),
None,
"a line emitted after the window supersedes the held one"
);
}
/// The window is measured from the last *emitted* line, not the last offer: a
/// stream of held lines must not extend the silence.
#[test]
fn test_throttle_window_runs_from_the_last_emission() {
let throttle = Throttle::new(Duration::from_millis(250));
let start = Instant::now();
throttle.offer("first", start);
assert_eq!(
throttle.offer("held", start + Duration::from_millis(200)),
None
);
assert_eq!(
throttle.offer("next", start + Duration::from_millis(260)),
Some("next".to_string()),
"a held line must not restart the window"
);
}
/// A pending line is taken once. Taking it twice would re-emit a line the
/// display already shows.
#[test]
fn test_throttle_yields_a_held_line_only_once() {
let throttle = Throttle::new(Duration::from_millis(250));
let start = Instant::now();
throttle.offer("first", start);
throttle.offer("held", start + Duration::from_millis(50));
assert_eq!(throttle.take_pending(), Some("held".to_string()));
assert_eq!(throttle.take_pending(), None);
}
/// Restarting opens the window immediately, which is what lets a new attempt's
/// first line go out even when it arrives inside the previous attempt's window.
/// It also discards a held line: that line belongs to the attempt that just
/// ended, and the new attempt is about to clear the display.
#[test]
fn test_throttle_restart_opens_the_window_and_discards_the_held_line() {
let throttle = Throttle::new(Duration::from_millis(250));
let start = Instant::now();
throttle.offer("previous attempt", start);
throttle.offer("held", start + Duration::from_millis(10));
throttle.restart();
assert_eq!(throttle.take_pending(), None);
assert_eq!(
throttle.offer("new attempt", start + Duration::from_millis(20)),
Some("new attempt".to_string())
);
}
// ── cut-edge erosion ─────────────────────────────────────────────────────────
/// A secret cut in half by the head cap must not survive as a fragment.
/// Redaction matches whole tokens — a prefixed secret up to the next whitespace
/// — so `nsec1qqq…` cut mid-value would still be scrubbed, but the *tail* of
/// that same value, having lost its prefix, would not be. Both cut edges drop
/// their partial token for that reason.
#[test]
fn test_capture_drops_the_partial_token_at_each_cut_edge() {
// Positioned so the head cap lands inside the first secret and the tail cap
// inside the second.
let head_secret = "nsec1headsecretvalue";
let tail_secret = "nsec1tailsecretvalue";
let input = format!(
"{} {head_secret} {} {tail_secret} {}",
"h".repeat(500),
"m".repeat(4000),
"t".repeat(1010)
);
let out = ui(&[input.as_bytes()]);
assert!(out.contains("bytes omitted"), "input must exceed the cap");
for fragment in ["nsec1head", "secretvalue"] {
assert!(
!out.contains(fragment),
"a fragment of a cut token must not survive: {out}"
);
}
}
/// Erosion stops at the nearest whitespace, so it costs one partial token and
/// not the surrounding output — the head's earlier lines and the tail's later
/// ones are what make a truncated capture readable.
#[test]
fn test_capture_erosion_keeps_the_complete_tokens_around_the_cut() {
let input = format!(
"opening line
{}
cut-here-head{}cut-here-tail
{}
closing line
",
"h".repeat(480),
"m".repeat(4000),
"t".repeat(980)
);
let out = ui(&[input.as_bytes()]);
assert!(out.starts_with("opening line\n"), "got: {out}");
assert!(out.ends_with("closing line\n"), "got: {out}");
}
/// A cut inside a whitespace-free run longer than the erosion window is left
/// intact. Erosion is bounded on purpose: erasing kilobytes of a single-token
/// stream — `npm` progress bars and base64 payloads both look like this — would
/// cost more diagnostics than a fragment of one could leak.
#[test]
fn test_capture_of_one_giant_token_keeps_its_cut_edges() {
let input = "x".repeat(4000);
let out = ui(&[input.as_bytes()]);
assert!(out.starts_with(&"x".repeat(512)), "got: {out}");
assert!(out.ends_with(&"x".repeat(1024)), "got: {out}");
}
/// The marker's byte count stays honest across erosion: what it names as omitted
/// must equal the input minus what is actually shown, or a reader cannot trust
/// the file to say how much is missing.
#[test]
fn test_capture_marker_counts_the_bytes_erosion_dropped() {
let input = format!(
"{} {} {}",
"h".repeat(600),
"m".repeat(4000),
"t".repeat(1100)
);
let out = ui(&[input.as_bytes()]);
let (head, rest) = out.split_once('\n').expect("a marker line");
let (marker, tail) = rest.split_once('\n').expect("a marker line");
let omitted: usize = marker
.trim_start_matches("... (")
.split_once(' ')
.expect("a byte count")
.0
.parse()
.expect("a byte count");
assert_eq!(
head.len() + omitted + tail.len(),
input.len(),
"shown + omitted must account for every input byte"
);
}