Claude Code Plugins

Community-maintained marketplace

Feedback

Testing patterns for GPUI applications

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name gpui-testing
description Testing patterns for GPUI applications
tags rust, gpui, testing, unit-tests

gpui-testing

Comprehensive guide to testing GPUI applications based on patterns from script-kit-gpui.

Overview

GPUI applications require specific testing patterns because they deal with:

  • UI state management - Views, components, state machines
  • Async operations - Background tasks, channels, spawn
  • Platform integration - Hotkeys, system calls, OS-specific behavior
  • Serialization - Config files, theme parsing, protocol messages

Test File Organization

Convention: *_tests.rs Files

Tests are organized in separate *_tests.rs files alongside their implementation:

src/
  theme/
    mod.rs           # Theme types and logic
    theme_tests.rs   # Theme tests
  config/
    mod.rs           # Config types
    config_tests.rs  # Config tests
  components/
    unified_list_item.rs
    unified_list_item_tests.rs

Import tests in the module:

// In mod.rs
#[cfg(test)]
mod theme_tests;

Inline Test Modules

For smaller modules, use inline test modules:

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_something() {
        // ...
    }
}

Test Setup Patterns

Helper Functions

Create helpers to reduce test boilerplate:

/// Helper to create a test Scriptlet with minimal required fields
fn test_scriptlet(name: &str, tool: &str, code: &str) -> Scriptlet {
    Scriptlet {
        name: name.to_string(),
        description: None,
        code: code.to_string(),
        tool: tool.to_string(),
        shortcut: None,
        expand: None,
        group: None,
        file_path: None,
        command: None,
        alias: None,
    }
}

/// Helper to wrap Vec<Script> into Vec<Arc<Script>> for tests
fn wrap_scripts(scripts: Vec<Script>) -> Vec<Arc<Script>> {
    scripts.into_iter().map(Arc::new).collect()
}

/// Helper to create a shortcut with modifiers
fn make_shortcut(key: &str, cmd: bool, shift: bool) -> Shortcut {
    Shortcut {
        key: key.to_string(),
        modifiers: Modifiers {
            cmd,
            shift,
            ..Default::default()
        },
    }
}

Test Fixtures

For complex types, create fixture factories:

fn create_test_config() -> Config {
    Config {
        hotkey: HotkeyConfig {
            modifiers: vec!["meta".to_string()],
            key: "Semicolon".to_string(),
        },
        bun_path: None,
        editor: None,
        padding: None,
        // ... other fields with defaults
    }
}

Testing Pure Logic (No GPUI Context)

Default Values

#[test]
fn test_default_config() {
    let config = Config::default();
    assert_eq!(config.hotkey.modifiers, vec!["meta"]);
    assert_eq!(config.hotkey.key, "Semicolon");
    assert_eq!(config.bun_path, None);
}

Serialization Roundtrip

#[test]
fn test_config_serialization() {
    let config = Config { /* ... */ };
    let json = serde_json::to_string(&config).unwrap();
    let deserialized: Config = serde_json::from_str(&json).unwrap();
    assert_eq!(deserialized.hotkey.key, config.hotkey.key);
}

JSON Deserialization Edge Cases

#[test]
fn test_config_deserialization_minimal() {
    let json = r#"{
        "hotkey": {
            "modifiers": ["meta"],
            "key": "Semicolon"
        }
    }"#;
    let config: Config = serde_json::from_str(json).unwrap();
    assert_eq!(config.bun_path, None);  // Missing fields default
}

#[test]
fn test_hex_color_parse_multiple_formats() {
    assert_eq!(parse_color_string("#FBBF24").unwrap(), 0xFBBF24);
    assert_eq!(parse_color_string("0xFBBF24").unwrap(), 0xFBBF24);
    assert_eq!(parse_color_string("rgb(251, 191, 36)").unwrap(), 0xFBBF24);
}

Testing State Machines

Registry Pattern

#[test]
fn register_and_get() {
    let mut registry = ShortcutRegistry::new();
    let binding = ShortcutBinding::builtin(
        "test.action",
        "Test Action",
        make_shortcut("k", true, false),
        ShortcutContext::Global,
        ShortcutCategory::Actions,
    );
    registry.register(binding);

    let retrieved = registry.get("test.action").unwrap();
    assert_eq!(retrieved.name, "Test Action");
}

#[test]
fn user_override_takes_precedence() {
    let mut registry = ShortcutRegistry::new();
    registry.register(/* ... */);
    
    let shortcut = registry.get_shortcut("test.action").unwrap();
    assert_eq!(shortcut.key, "k");  // Default
    
    registry.set_override("test.action", Some(make_shortcut("j", true, true)));
    
    let shortcut = registry.get_shortcut("test.action").unwrap();
    assert_eq!(shortcut.key, "j");  // Overridden
}

Channel Testing

#[test]
fn hotkey_channels_are_independent() {
    // Clear channels
    while hotkey_channel().1.try_recv().is_ok() {}
    while script_hotkey_channel().1.try_recv().is_ok() {}

    // Send to one channel
    hotkey_channel().0.send_blocking(()).expect("send hotkey");
    
    // Verify other channel is empty
    assert!(matches!(
        script_hotkey_channel().1.try_recv(),
        Err(TryRecvError::Empty)
    ));
    
    // Verify original channel has message
    assert!(hotkey_channel().1.try_recv().is_ok());
}

Testing Components

Type Verification

#[test]
fn test_list_item_colors_is_copy() {
    // Compile-time verification that type implements Copy
    fn assert_copy<T: Copy>() {}
    assert_copy::<ListItemColors>();
}

Layout Calculations

#[test]
fn test_density_comfortable_layout() {
    let layout = ListItemLayout::from_density(Density::Comfortable);
    assert_eq!(layout.height, 48.0);
    assert!(layout.padding_x >= 12.0);
}

#[test]
fn test_layout_height_is_fixed() {
    let comfortable = ListItemLayout::from_density(Density::Comfortable);
    let compact = ListItemLayout::from_density(Density::Compact);
    
    assert!(comfortable.height > 0.0);
    assert!(compact.height > 0.0);
    assert!(comfortable.height > compact.height);
}

UTF-8 Safety

#[test]
fn test_split_by_ranges_emoji_safe() {
    // "a[emoji]b" - emoji is 4 bytes
    let text = "a\u{1F600}b";
    let ranges = vec![1..5];  // The emoji bytes
    
    let spans = split_by_ranges(text, &ranges);
    assert_eq!(spans[0], ("a", false));
    assert_eq!(spans[1], ("\u{1F600}", true));
    assert_eq!(spans[2], ("b", false));
}

#[test]
fn test_split_by_ranges_japanese() {
    // Each Japanese char is 3 bytes
    let text = "\u{65E5}\u{672C}\u{8A9E}";  // "nihongo"
    let ranges = vec![3..6];  // Middle character
    
    let spans = split_by_ranges(text, &ranges);
    assert_eq!(spans[1].0.chars().next().unwrap(), '\u{672C}');
}

System Tests with Feature Flags

Use #[cfg(feature = "system-tests")] for tests that:

  • Require OS permissions (accessibility, hotkeys)
  • Open real windows/applications
  • Interact with system services
#[cfg(feature = "system-tests")]
#[test]
fn test_handle_get_selected_text_returns_handled() {
    let msg = Message::get_selected_text("req-001".to_string());
    let result = handle_selected_text_message(&msg);
    // ...
}

// This test actually opens Finder
#[cfg(all(unix, feature = "system-tests"))]
#[test]
fn test_run_scriptlet_open() {
    let scriptlet = Scriptlet::new(
        "Open Test".to_string(),
        "open".to_string(),
        "/tmp".to_string(),
    );
    let result = run_scriptlet(&scriptlet, ScriptletExecOptions::default());
    assert!(result.is_ok());
}

Run system tests with:

cargo test --features system-tests

Platform-Specific Tests

#[cfg(unix)]
#[test]
fn test_spawn_and_kill_process() {
    let result = spawn_script("sleep", &["10"], "[test:sleep]");
    if let Ok(mut session) = result {
        assert!(session.is_running());
        session.kill().expect("kill should succeed");
        std::thread::sleep(std::time::Duration::from_millis(100));
        assert!(!session.is_running());
    }
}

#[cfg(target_os = "macos")]
#[test]
fn test_find_conflicts_detects_os_reserved() {
    let mut registry = ShortcutRegistry::new();
    // Cmd+Tab is OS reserved on macOS
    registry.register(ShortcutBinding::builtin(
        "app.switcher",
        "App Switcher",
        make_shortcut("tab", true, false),
        ShortcutContext::Global,
        ShortcutCategory::System,
    ));
    
    let conflicts = registry.find_conflicts();
    assert!(conflicts.iter().any(|c| 
        c.conflict_type == ConflictType::Unreachable
    ));
}

Environment Variable Testing

Handle env var tests carefully (they're global):

#[test]
fn test_get_editor_from_env() {
    // Save current value
    let original_editor = std::env::var("EDITOR").ok();
    
    // Test with custom value
    std::env::set_var("EDITOR", "emacs");
    let config = Config::default();
    assert_eq!(config.get_editor(), "emacs");
    
    // Restore original value
    match original_editor {
        Some(val) => std::env::set_var("EDITOR", val),
        None => std::env::remove_var("EDITOR"),
    }
}

For parallel-safe env testing, test all cases sequentially in one test:

#[test]
fn test_is_auto_submit_enabled_all_cases() {
    std::env::set_var("AUTO_SUBMIT", "true");
    assert!(is_auto_submit_enabled());
    
    std::env::set_var("AUTO_SUBMIT", "1");
    assert!(is_auto_submit_enabled());
    
    std::env::set_var("AUTO_SUBMIT", "false");
    assert!(!is_auto_submit_enabled());
    
    std::env::remove_var("AUTO_SUBMIT");
    assert!(!is_auto_submit_enabled());
}

Code Audit Tests

Test invariants about the codebase itself:

#[test]
fn test_no_direct_cx_hide_in_app_execute() {
    let content = fs::read_to_string("src/app_execute.rs")
        .unwrap_or_default();
    let matches = find_lines_with_pattern(&content, "cx.hide()");
    
    assert!(
        matches.is_empty(),
        "Found forbidden cx.hide() in app_execute.rs. Use self.close_and_reset_window(cx) instead."
    );
}

#[test]
fn test_close_and_reset_window_exists() {
    let content = fs::read_to_string("src/app_impl.rs")
        .unwrap_or_default();
    let count = content.matches("fn close_and_reset_window").count();
    
    assert!(count >= 1, "close_and_reset_window() not found");
}

Testing GPUI Keystroke Matching

#[test]
fn find_match_respects_context_order() {
    let mut registry = ShortcutRegistry::new();
    registry.register(ShortcutBinding::builtin(
        "editor.enter",
        "Editor Enter",
        make_shortcut("enter", false, false),
        ShortcutContext::Editor,
        ShortcutCategory::Actions,
    ));
    registry.register(ShortcutBinding::builtin(
        "global.enter",
        "Global Enter",
        make_shortcut("enter", false, false),
        ShortcutContext::Global,
        ShortcutCategory::Actions,
    ));

    let keystroke = gpui::Keystroke {
        key: "enter".to_string(),
        key_char: None,
        modifiers: gpui::Modifiers::default(),
    };

    // Editor context first - should match editor binding
    let contexts = [ShortcutContext::Editor, ShortcutContext::Global];
    assert_eq!(
        registry.find_match(&keystroke, &contexts),
        Some("editor.enter")
    );
}

Testing with GlobalHotKeyManager

System hotkey tests may fail in CI (no event loop):

mod script_hotkey_manager_tests {
    fn create_test_manager() -> Option<ScriptHotkeyManager> {
        // May fail in test environment
        GlobalHotKeyManager::new()
            .ok()
            .map(ScriptHotkeyManager::new)
    }

    #[test]
    fn test_manager_creation() {
        if let Some(manager) = create_test_manager() {
            assert!(manager.hotkey_map.is_empty());
        }
        // If creation failed, test passes (expected in CI)
    }

    #[test]
    fn test_register_tracks_mapping() {
        if let Some(mut manager) = create_test_manager() {
            let result = manager.register("/test/script.ts", "cmd+shift+t");
            if result.is_ok() {
                assert!(manager.is_registered("/test/script.ts"));
            }
            // Registration may fail without event loop - that's OK
        }
    }
}

Anti-patterns

DON'T: Use cx.run() in Unit Tests

GPUI context methods require a running app. Unit tests should test pure logic.

DON'T: Rely on Global State

// BAD - tests may interfere with each other
static mut COUNTER: i32 = 0;

#[test]
fn test_increment() {
    unsafe { COUNTER += 1; }
    // ...
}

DON'T: Hardcode Paths

// BAD
let path = "/Users/john/.scriptkit/scripts/test.ts";

// GOOD - use temp dirs
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test.ts");

DON'T: Forget Platform Guards

// BAD - will fail on Windows
#[test]
fn test_unix_signals() {
    use libc::kill;
    // ...
}

// GOOD
#[cfg(unix)]
#[test]
fn test_unix_signals() {
    use libc::kill;
    // ...
}

DON'T: Skip Cleanup

// BAD
#[test]
fn test_with_env() {
    std::env::set_var("MY_VAR", "value");
    // Test crashes - env var leaked
}

// GOOD
#[test]
fn test_with_env() {
    let original = std::env::var("MY_VAR").ok();
    std::env::set_var("MY_VAR", "value");
    
    // ... test code ...
    
    // Always cleanup
    match original {
        Some(v) => std::env::set_var("MY_VAR", v),
        None => std::env::remove_var("MY_VAR"),
    }
}

Running Tests

# Run all tests
cargo test

# Run tests for a specific module
cargo test theme_tests

# Run tests with system features
cargo test --features system-tests

# Run tests with output
cargo test -- --nocapture

# Run a single test
cargo test test_default_config