diff --git a/.github/workflows/ci.pinnacle.yml b/.github/workflows/ci.pinnacle.yml index 847a871..51b8b13 100644 --- a/.github/workflows/ci.pinnacle.yml +++ b/.github/workflows/ci.pinnacle.yml @@ -53,10 +53,10 @@ jobs: uses: leafo/gh-actions-luarocks@v4 - name: Build if: ${{ runner.debug != '1' }} - run: cargo test + run: cargo test -- --test-threads=1 - name: Build (debug) if: ${{ runner.debug == '1' }} - run: RUST_LOG=debug cargo test -- --nocapture + run: RUST_LOG=debug cargo test -- --nocapture --test-threads=1 check-format: runs-on: ubuntu-latest name: Check formatting @@ -79,6 +79,8 @@ jobs: uses: dtolnay/rust-toolchain@stable with: components: clippy + - name: Cache stuff + uses: Swatinem/rust-cache@v2 - name: Get dependencies run: sudo apt update && sudo apt install libwayland-dev libxkbcommon-dev libudev-dev libinput-dev libgbm-dev libseat-dev libsystemd-dev protobuf-compiler xwayland libegl-dev - name: Setup Lua diff --git a/api/lua/pinnacle/window.lua b/api/lua/pinnacle/window.lua index bc594ac..73ae610 100644 --- a/api/lua/pinnacle/window.lua +++ b/api/lua/pinnacle/window.lua @@ -224,7 +224,7 @@ local _fullscreen_or_maximized_keys = { --- -- A simple window rule. This one will cause Firefox to open on tag "Browser". ---Window.add_window_rule({ --- cond = { classes = { "firefox" } }, ---- rule = { tags = { "Browser" } }, +--- rule = { tags = { Tag.get("Browser") } }, ---}) --- --- -- To apply rules when *all* provided conditions are true, use `all`. @@ -234,8 +234,8 @@ local _fullscreen_or_maximized_keys = { --- cond = { --- all = { --- { ---- class = "steam", ---- tag = Tag:get("5"), +--- classes = { "steam" }, +--- tags = { Tag.get("5") }, --- } --- } --- }, @@ -246,8 +246,8 @@ local _fullscreen_or_maximized_keys = { --- -- Thus, the above can be shortened to: ---Window.add_window_rule({ --- cond = { ---- class = "steam", ---- tag = Tag:get("5"), +--- classes = { "steam" }, +--- tags = { Tag.get("5") }, --- }, --- rule = { fullscreen_or_maximized = "fullscreen" }, ---}) @@ -268,17 +268,17 @@ local _fullscreen_or_maximized_keys = { --- cond = { --- all = { -- This `all` block is needed because the outermost block cannot be an array. --- { any = { ---- { class = { "firefox", "thunderbird", "discord" } } +--- { classes = { "firefox", "thunderbird", "discord" } } --- } }, --- { any = { --- -- Because `tag` is inside an `all` block, --- -- the window must have all these tags for this to be true. --- -- If it was in an `any` block, only one tag would need to match. --- { all = { ---- { tag = { "A", "B", "C" } } +--- { tags = { Tag.get("A"), Tag.get("B"), Tag.get("C") } } --- } }, --- { all = { ---- { tag = { "1", "2" } } +--- { tags = { Tag.get("1"), Tag.get("2") } } --- } }, --- } } --- } diff --git a/src/cli.rs b/src/cli.rs index 4173574..94b89dd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -412,6 +412,8 @@ fn generate_config(args: ConfigGen) -> anyhow::Result<()> { #[cfg(test)] mod tests { + use anyhow::Context; + use super::*; // TODO: find a way to test the interactive bits programmatically @@ -428,9 +430,7 @@ mod tests { "--lang", "rust", "--dir", - temp_dir - .to_str() - .ok_or(anyhow::anyhow!("not valid unicode"))?, + temp_dir.to_str().context("not valid unicode")?, "--non-interactive", ]); diff --git a/src/window/rules.rs b/src/window/rules.rs index c32754f..8e5004d 100644 --- a/src/window/rules.rs +++ b/src/window/rules.rs @@ -14,7 +14,7 @@ use std::num::NonZeroU32; use crate::{output::OutputName, tag::TagId, window::window_state::FullscreenOrMaximized}; -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)] pub struct WindowRuleCondition { /// This condition is met when any of the conditions provided is met. #[serde(default)] @@ -137,7 +137,7 @@ impl WindowRuleCondition { } } -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)] pub struct WindowRule { /// Set the output the window will open on. #[serde(default)] diff --git a/tests/lua_api.rs b/tests/lua_api.rs index 7b6ee80..a2c6588 100644 --- a/tests/lua_api.rs +++ b/tests/lua_api.rs @@ -1,10 +1,14 @@ use std::{ io::Write, + panic::UnwindSafe, process::{Command, Stdio}, time::Duration, }; -use pinnacle::{backend::dummy::setup_dummy, state::State}; +use pinnacle::{ + backend::dummy::setup_dummy, + state::{State, WithState}, +}; use smithay::reexports::calloop::{ self, channel::{Event, Sender}, @@ -13,21 +17,35 @@ use smithay::reexports::calloop::{ use test_log::test; fn run_lua(ident: &str, code: &str) { - let code = format!(r#"require("pinnacle").setup(function({ident}) {code} end)"#); + #[rustfmt::skip] + let code = format!(r#" + require("pinnacle").setup(function({ident}) + local run = function({ident}) + {code} + end + + local success, err = pcall(run, {ident}) + + if not success then + print(err) + os.exit(1) + end + end) + "#); let mut child = Command::new("lua").stdin(Stdio::piped()).spawn().unwrap(); - let mut stdin = child - .stdin - .take() - .ok_or_else(|| anyhow::anyhow!("failed to open child stdin")) - .unwrap(); + let mut stdin = child.stdin.take().unwrap(); stdin.write_all(code.as_bytes()).unwrap(); drop(stdin); - child.wait().unwrap(); + let exit_status = child.wait().unwrap(); + + if exit_status.code().is_some_and(|code| code != 0) { + panic!("lua code panicked"); + } } #[allow(clippy::type_complexity)] @@ -49,7 +67,7 @@ macro_rules! run_lua { } fn test_lua_api( - test: impl FnOnce(Sender>) + Send + 'static, + test: impl FnOnce(Sender>) + Send + UnwindSafe + 'static, ) -> anyhow::Result<()> { let (mut state, mut event_loop) = setup_dummy(true, None)?; @@ -59,7 +77,7 @@ fn test_lua_api( .handle() .insert_source(recv, |event, _, state| match event { Event::Msg(f) => f(state), - Event::Closed => panic!(), + Event::Closed => (), }) .map_err(|_| anyhow::anyhow!("failed to insert source"))?; @@ -67,7 +85,17 @@ fn test_lua_api( state.start_grpc_server(tempdir.path())?; - std::thread::spawn(move || test(sender)); + let loop_signal = event_loop.get_signal(); + + let join_handle = std::thread::spawn(move || { + let res = std::panic::catch_unwind(|| { + test(sender); + }); + loop_signal.stop(); + if let Err(err) = res { + std::panic::resume_unwind(err); + } + }); event_loop.run(None, &mut state, |state| { state.fixup_z_layering(); @@ -88,15 +116,277 @@ fn test_lua_api( ); })?; + if let Err(err) = join_handle.join() { + panic!("{err:?}"); + } + Ok(()) } +mod coverage { + use pinnacle::{ + tag::TagId, + window::{ + rules::{WindowRule, WindowRuleCondition}, + window_state::FullscreenOrMaximized, + }, + }; + + use super::*; + + // Process + + #[tokio::main] + #[self::test] + async fn process_spawn() -> anyhow::Result<()> { + test_lua_api(|sender| { + run_lua! { |Pinnacle| + Pinnacle.process.spawn("foot") + } + + sleep_secs(1); + + assert(&sender, |state| { + assert_eq!(state.windows.len(), 1); + assert_eq!(state.windows[0].class(), Some("foot".to_string())); + }); + }) + } + + #[tokio::main] + #[self::test] + async fn process_set_env() -> anyhow::Result<()> { + test_lua_api(|sender| { + run_lua! { |Pinnacle| + Pinnacle.process.set_env("PROCESS_SET_ENV", "env value") + } + + sleep_secs(1); + + assert(&sender, |_state| { + assert_eq!( + std::env::var("PROCESS_SET_ENV"), + Ok("env value".to_string()) + ); + }); + }) + } + + // Window + + #[tokio::main] + #[self::test] + async fn window_get_all() -> anyhow::Result<()> { + test_lua_api(|_sender| { + run_lua! { |Pinnacle| + assert(#Pinnacle.window.get_all() == 0) + + for i = 1, 5 do + Pinnacle.process.spawn("foot") + end + } + + sleep_secs(1); + + run_lua! { |Pinnacle| + assert(#Pinnacle.window.get_all() == 5) + } + }) + } + + #[tokio::main] + #[self::test] + async fn window_get_focused() -> anyhow::Result<()> { + test_lua_api(|_sender| { + run_lua! { |Pinnacle| + assert(not Pinnacle.window.get_focused()) + + Pinnacle.tag.add(Pinnacle.output.get_focused(), "1")[1]:set_active(true) + Pinnacle.process.spawn("foot") + } + + sleep_secs(1); + + run_lua! { |Pinnacle| + assert(Pinnacle.window.get_focused()) + } + }) + } + + #[tokio::main] + #[self::test] + async fn window_add_window_rule() -> anyhow::Result<()> { + test_lua_api(|sender| { + run_lua! { |Pinnacle| + Pinnacle.tag.add(Pinnacle.output.get_focused(), "Tag Name") + Pinnacle.window.add_window_rule({ + cond = { classes = { "firefox" } }, + rule = { tags = { Pinnacle.tag.get("Tag Name") } }, + }) + } + + sleep_secs(1); + + assert(&sender, |state| { + assert_eq!(state.config.window_rules.len(), 1); + assert_eq!( + state.config.window_rules[0], + ( + WindowRuleCondition { + class: Some(vec!["firefox".to_string()]), + ..Default::default() + }, + WindowRule { + tags: Some(vec![TagId::Some(0)]), + ..Default::default() + } + ) + ); + }); + + run_lua! { |Pinnacle| + Pinnacle.tag.add(Pinnacle.output.get_focused(), "Tag Name 2") + Pinnacle.window.add_window_rule({ + cond = { + all = { + { + classes = { "steam" }, + tags = { + Pinnacle.tag.get("Tag Name"), + Pinnacle.tag.get("Tag Name 2"), + }, + } + } + }, + rule = { fullscreen_or_maximized = "fullscreen" }, + }) + } + + sleep_secs(1); + + assert(&sender, |state| { + assert_eq!(state.config.window_rules.len(), 2); + assert_eq!( + state.config.window_rules[1], + ( + WindowRuleCondition { + cond_all: Some(vec![WindowRuleCondition { + class: Some(vec!["steam".to_string()]), + tag: Some(vec![TagId::Some(0), TagId::Some(1)]), + ..Default::default() + }]), + ..Default::default() + }, + WindowRule { + fullscreen_or_maximized: Some(FullscreenOrMaximized::Fullscreen), + ..Default::default() + } + ) + ); + }); + }) + } + + // TODO: window_begin_move + // TODO: window_begin_resize + + // WindowHandle + + #[tokio::main] + #[self::test] + async fn window_handle_close() -> anyhow::Result<()> { + test_lua_api(|sender| { + run_lua! { |Pinnacle| + Pinnacle.process.spawn("foot") + } + + sleep_secs(1); + + assert(&sender, |state| { + assert_eq!(state.windows.len(), 1); + }); + + run_lua! { |Pinnacle| + Pinnacle.window.get_all()[1]:close() + } + + sleep_secs(1); + + assert(&sender, |state| { + assert_eq!(state.windows.len(), 0); + }); + }) + } + + #[tokio::main] + #[self::test] + async fn window_handle_move_to_tag() -> anyhow::Result<()> { + test_lua_api(|sender| { + run_lua! { |Pinnacle| + local tags = Pinnacle.tag.add(Pinnacle.output.get_focused(), "1", "2", "3") + tags[1]:set_active(true) + tags[2]:set_active(true) + Pinnacle.process.spawn("foot") + } + + sleep_secs(1); + + assert(&sender, |state| { + assert_eq!( + state.windows[0].with_state(|st| st + .tags + .iter() + .map(|tag| tag.name()) + .collect::>()), + vec!["1", "2"] + ); + }); + + // Correct usage + run_lua! { |Pinnacle| + Pinnacle.window.get_all()[1]:move_to_tag(Pinnacle.tag.get("3")) + } + + sleep_secs(1); + + assert(&sender, |state| { + assert_eq!( + state.windows[0].with_state(|st| st + .tags + .iter() + .map(|tag| tag.name()) + .collect::>()), + vec!["3"] + ); + }); + + // Move to the same tag + run_lua! { |Pinnacle| + Pinnacle.window.get_all()[1]:move_to_tag(Pinnacle.tag.get("3")) + } + + sleep_secs(1); + + assert(&sender, |state| { + assert_eq!( + state.windows[0].with_state(|st| st + .tags + .iter() + .map(|tag| tag.name()) + .collect::>()), + vec!["3"] + ); + }); + }) + } +} + #[tokio::main] #[test] async fn window_count_with_tag_is_correct() -> anyhow::Result<()> { test_lua_api(|sender| { run_lua! { |Pinnacle| - Pinnacle.tag.add(Pinnacle.output.get_all()[1], "1") + Pinnacle.tag.add(Pinnacle.output.get_focused(), "1") Pinnacle.process.spawn("foot") } @@ -104,10 +394,107 @@ async fn window_count_with_tag_is_correct() -> anyhow::Result<()> { assert(&sender, |state| assert_eq!(state.windows.len(), 1)); + run_lua! { |Pinnacle| + for i = 1, 20 do + Pinnacle.process.spawn("foot") + end + } + sleep_secs(1); - run_lua! { |Pinnacle| - Pinnacle.quit() - } + assert(&sender, |state| assert_eq!(state.windows.len(), 21)); + }) +} + +#[tokio::main] +#[test] +async fn window_count_without_tag_is_correct() -> anyhow::Result<()> { + test_lua_api(|sender| { + run_lua! { |Pinnacle| + Pinnacle.process.spawn("foot") + } + + sleep_secs(1); + + assert(&sender, |state| assert_eq!(state.windows.len(), 1)); + }) +} + +#[tokio::main] +#[test] +async fn spawned_window_on_active_tag_has_keyboard_focus() -> anyhow::Result<()> { + test_lua_api(|sender| { + run_lua! { |Pinnacle| + Pinnacle.tag.add(Pinnacle.output.get_focused(), "1")[1]:set_active(true) + Pinnacle.process.spawn("foot") + } + + sleep_secs(1); + + assert(&sender, |state| { + assert_eq!( + state + .focused_window(state.focused_output().unwrap()) + .unwrap() + .class(), + Some("foot".to_string()) + ); + }); + }) +} + +#[tokio::main] +#[test] +async fn spawned_window_on_inactive_tag_does_not_have_keyboard_focus() -> anyhow::Result<()> { + test_lua_api(|sender| { + run_lua! { |Pinnacle| + Pinnacle.tag.add(Pinnacle.output.get_focused(), "1") + Pinnacle.process.spawn("foot") + } + + sleep_secs(1); + + assert(&sender, |state| { + assert_eq!(state.focused_window(state.focused_output().unwrap()), None); + }); + }) +} + +#[tokio::main] +#[test] +async fn spawned_window_has_correct_tags() -> anyhow::Result<()> { + test_lua_api(|sender| { + run_lua! { |Pinnacle| + Pinnacle.tag.add(Pinnacle.output.get_focused(), "1", "2", "3") + Pinnacle.process.spawn("foot") + } + + sleep_secs(1); + + assert(&sender, |state| { + assert_eq!(state.windows.len(), 1); + assert_eq!(state.windows[0].with_state(|st| st.tags.len()), 1); + }); + + run_lua! { |Pinnacle| + Pinnacle.tag.get("1"):set_active(true) + Pinnacle.tag.get("3"):set_active(true) + Pinnacle.process.spawn("foot") + } + + sleep_secs(1); + + assert(&sender, |state| { + assert_eq!(state.windows.len(), 2); + assert_eq!(state.windows[1].with_state(|st| st.tags.len()), 2); + assert_eq!( + state.windows[1].with_state(|st| st + .tags + .iter() + .map(|tag| tag.name()) + .collect::>()), + vec!["1", "3"] + ); + }); }) }