diff --git a/testing/Cargo.toml b/testing/Cargo.toml index 5bcdc0f..bfc892b 100644 --- a/testing/Cargo.toml +++ b/testing/Cargo.toml @@ -14,3 +14,7 @@ libc = "0.2" lesavka_client = { path = "../client" } lesavka_common = { path = "../common" } lesavka_server = { path = "../server" } +chacha20poly1305 = "0.10" +serial_test = { workspace = true } +temp-env = { workspace = true } +tempfile = { workspace = true } diff --git a/testing/tests/client_layout_contract.rs b/testing/tests/client_layout_contract.rs new file mode 100644 index 0000000..73af553 --- /dev/null +++ b/testing/tests/client_layout_contract.rs @@ -0,0 +1,99 @@ +//! Integration coverage for the client window layout contract. +//! +//! Scope: exercise `layout::apply` end-to-end against a fake `swaymsg` binary. +//! Targets: `client/src/layout.rs`. +//! Why: this keeps layout policy coverage centralized in `testing/tests` while +//! avoiding source-LOC ratchet growth in the client crate. + +use lesavka_client::layout::{Layout, apply}; +use serial_test::serial; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use temp_env::with_var; +use tempfile::tempdir; + +fn install_sway_stub(dir: &std::path::Path) -> std::path::PathBuf { + let sway_path = dir.join("swaymsg"); + fs::write( + &sway_path, + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "-t" && "${2:-}" == "get_outputs" && "${3:-}" == "-r" ]]; then + printf '%s\n' "${LESAVKA_TEST_SWAY_OUTPUTS:-[]}" + exit 0 +fi +echo "$*" >> "${LESAVKA_TEST_SWAY_LOG}" +"#, + ) + .expect("write swaymsg stub"); + let mut perms = fs::metadata(&sway_path) + .expect("read swaymsg stub metadata") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&sway_path, perms).expect("mark swaymsg stub executable"); + sway_path +} + +fn run_apply_with_outputs(layout: Layout, outputs_json: &str) -> Option { + let dir = tempdir().expect("create temp dir"); + install_sway_stub(dir.path()); + let log_path = dir.path().join("sway.log"); + let old_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{old_path}", dir.path().display()); + let log_value = log_path.to_string_lossy().to_string(); + + with_var("PATH", Some(new_path.as_str()), || { + with_var("LESAVKA_TEST_SWAY_LOG", Some(log_value.as_str()), || { + with_var("LESAVKA_TEST_SWAY_OUTPUTS", Some(outputs_json), || { + apply(layout); + }); + }); + }); + + fs::read_to_string(log_path).ok() +} + +#[test] +#[serial] +fn side_by_side_places_each_eye_on_half_of_focused_output() { + let outputs = r#"[{"focused":true,"rect":{"x":10,"y":20,"width":1920,"height":1080}}]"#; + let log = run_apply_with_outputs(Layout::SideBySide, outputs).expect("read command log"); + assert!(log.contains(r#"[title="^Lesavka-eye-0$"] resize set 960 1080; move position 10 20"#)); + assert!(log.contains(r#"[title="^Lesavka-eye-1$"] resize set 960 1080; move position 970 20"#)); +} + +#[test] +#[serial] +fn full_layout_modes_hide_the_opposite_eye_offscreen() { + let outputs = r#"[{"focused":true,"rect":{"x":100,"y":50,"width":640,"height":360}}]"#; + let left = run_apply_with_outputs(Layout::FullLeft, outputs).expect("left layout log"); + assert!(left.contains(r#"[title="^Lesavka-eye-0$"] resize set 640 360; move position 100 50"#)); + assert!(left.contains(r#"[title="^Lesavka-eye-1$"] resize set 1 1; move position 1380 770"#)); + + let right = run_apply_with_outputs(Layout::FullRight, outputs).expect("right layout log"); + assert!( + right.contains(r#"[title="^Lesavka-eye-1$"] resize set 640 360; move position 100 50"#) + ); + assert!(right.contains(r#"[title="^Lesavka-eye-0$"] resize set 1 1; move position 1380 770"#)); +} + +#[test] +#[serial] +fn apply_skips_window_commands_when_outputs_payload_is_invalid() { + let log = run_apply_with_outputs(Layout::SideBySide, "{not-json").unwrap_or_default(); + assert!( + log.trim().is_empty(), + "expected no placement calls, got: {log}" + ); +} + +#[test] +#[serial] +fn apply_skips_window_commands_when_no_output_is_focused() { + let outputs = r#"[{"focused":false,"rect":{"x":0,"y":0,"width":1920,"height":1080}}]"#; + let log = run_apply_with_outputs(Layout::SideBySide, outputs).unwrap_or_default(); + assert!( + log.trim().is_empty(), + "expected no placement calls, got: {log}" + ); +} diff --git a/testing/tests/client_paste_contract.rs b/testing/tests/client_paste_contract.rs new file mode 100644 index 0000000..64d16cc --- /dev/null +++ b/testing/tests/client_paste_contract.rs @@ -0,0 +1,52 @@ +//! Integration coverage for the client paste-request contract. +//! +//! Scope: validate key loading, encryption metadata, and truncation behavior +//! through the public paste helper. +//! Targets: `client/src/paste.rs`. +//! Why: these checks are pure policy and crypto framing, so they belong in the +//! centralized `testing/tests` contract suite. + +use chacha20poly1305::aead::Aead; +use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce}; +use lesavka_client::paste::build_paste_request; +use serial_test::serial; +use temp_env::with_var; + +const TEST_KEY_HEX: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + +#[test] +#[serial] +fn build_paste_request_requires_a_shared_key() { + with_var("LESAVKA_PASTE_KEY", None::<&str>, || { + let err = build_paste_request("hello").expect_err("missing key should fail"); + assert!(format!("{err:#}").contains("LESAVKA_PASTE_KEY")); + }); +} + +#[test] +#[serial] +fn build_paste_request_sets_encryption_fields() { + with_var("LESAVKA_PASTE_KEY", Some(TEST_KEY_HEX), || { + let req = build_paste_request("hello world").expect("build request"); + assert!(req.encrypted); + assert_eq!(req.nonce.len(), 12); + assert!(!req.data.is_empty()); + }); +} + +#[test] +#[serial] +fn build_paste_request_truncates_plaintext_before_encryption() { + with_var("LESAVKA_PASTE_KEY", Some(TEST_KEY_HEX), || { + with_var("LESAVKA_PASTE_MAX", Some("5"), || { + let req = build_paste_request("hello-from-lesavka").expect("build truncated request"); + let key = lesavka_common::paste::decode_shared_key(TEST_KEY_HEX).expect("decode key"); + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + let nonce = Nonce::from_slice(&req.nonce); + let plaintext = cipher + .decrypt(nonce, req.data.as_ref()) + .expect("decrypt request data"); + assert_eq!(std::str::from_utf8(&plaintext).expect("utf8"), "hello"); + }); + }); +} diff --git a/testing/tests/common_cli_contract.rs b/testing/tests/common_cli_contract.rs new file mode 100644 index 0000000..34d6784 --- /dev/null +++ b/testing/tests/common_cli_contract.rs @@ -0,0 +1,12 @@ +//! Integration coverage for the common CLI entrypoint contract. +//! +//! Scope: execute the public common CLI helper from the centralized testing +//! crate. +//! Targets: `common/src/lib.rs`. +//! Why: this keeps even tiny user-facing helpers represented in cross-crate +//! contract coverage without package-local integration tests. + +#[test] +fn run_cli_executes_without_panicking() { + lesavka_common::run_cli(); +}