From 24148607ecf2817e5d31bc8079b9a8aa341b3349 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Wed, 19 Aug 2026 07:46:06 +0200 Subject: [PATCH 1/8] refactor(workspace, cli): Move workspace opening into `jp_workspace` `jp_cli::load_workspace` assembled a workspace from disk by hand: find the root, load the ID, build the filesystem backend, wire the user-local silo, then store the ID back. That is workspace knowledge living in the CLI, and a second consumer has to repeat it. Repeating it wrong is silent: wiring the filesystem backend without user-local storage still compiles and still returns conversations, just fewer of them. `Workspace::open` owns that sequence now, with `DEFAULT_STORAGE_DIR` moved alongside it. A new `Error::WorkspaceNotFound` carries the directory that was searched, so the CLI keeps printing its `jp init` hint while other callers receive a typed error rather than a formatted string. The two in-memory constructors become `in_memory` and `in_memory_with_id`, so each constructor says which side of the disk it sits on. Two accessors come with them: `fs_storage` returns the filesystem backend an opened workspace holds, and `sessions` returns the session backend, which the `--no-persist` path wraps in `ReadOnlySessionBackend` instead of reaching for the filesystem backend that happened to be serving that role. Behavior is unchanged. A test covers the wiring that is otherwise invisible when wrong: a conversation created with `--local` lives only in the user-local silo, and a workspace opened from disk must list it. Signed-off-by: Jean Mertz --- .../jp_attachment_internal/src/lib_tests.rs | 8 +- crates/jp_cli/src/cmd.rs | 5 + crates/jp_cli/src/cmd/attachment_tests.rs | 4 +- crates/jp_cli/src/cmd/config/set_tests.rs | 2 +- .../src/cmd/conversation/archive_tests.rs | 4 +- .../jp_cli/src/cmd/conversation/fork_tests.rs | 10 +- .../jp_cli/src/cmd/conversation/grep_tests.rs | 2 +- .../jp_cli/src/cmd/conversation/path_tests.rs | 2 +- .../src/cmd/conversation/print_tests.rs | 2 +- .../jp_cli/src/cmd/conversation/rm_tests.rs | 2 +- .../jp_cli/src/cmd/conversation/use_tests.rs | 4 +- crates/jp_cli/src/cmd/init.rs | 6 +- .../jp_cli/src/cmd/plugin/dispatch_tests.rs | 2 +- .../src/cmd/query/stream/retry_tests.rs | 2 +- .../jp_cli/src/cmd/query/turn_loop_tests.rs | 88 ++++++------ crates/jp_cli/src/cmd/query_tests.rs | 18 +-- crates/jp_cli/src/cmd/target_tests.rs | 10 +- crates/jp_cli/src/lib.rs | 51 ++----- crates/jp_cli/src/lib_tests.rs | 20 +-- crates/jp_cli/src/shared/search_tests.rs | 2 +- crates/jp_workspace/src/error.rs | 3 + crates/jp_workspace/src/lib.rs | 115 +++++++++++++-- crates/jp_workspace/src/lib_tests.rs | 134 ++++++++++++++++-- crates/jp_workspace/src/sanitize_tests.rs | 4 +- .../jp_workspace/src/session_mapping_tests.rs | 26 ++-- 25 files changed, 359 insertions(+), 167 deletions(-) diff --git a/crates/jp_attachment_internal/src/lib_tests.rs b/crates/jp_attachment_internal/src/lib_tests.rs index 62dc13eef..5b5e6a268 100644 --- a/crates/jp_attachment_internal/src/lib_tests.rs +++ b/crates/jp_attachment_internal/src/lib_tests.rs @@ -12,7 +12,7 @@ fn workspace_with_backend( id: jp_workspace::Id, backend: FsStorageBackend, ) -> Workspace { - let mut workspace = Workspace::new_with_id(root, id).with_backend(Arc::new(backend)); + let mut workspace = Workspace::in_memory_with_id(root, id).with_backend(Arc::new(backend)); workspace.load_conversation_index(); workspace } @@ -205,7 +205,7 @@ fn validate_accepts_valid_uri() { #[test] fn resolve_errors_when_conversation_is_not_loaded() { let tmp = camino_tempfile::tempdir().unwrap(); - let workspace = Workspace::new(tmp.path().to_path_buf()); + let workspace = Workspace::in_memory(tmp.path().to_path_buf()); let id = ConversationId::try_from_deciseconds(17_013_123_456).unwrap(); let uri = Url::parse(&format!("jp://{id}")).unwrap(); @@ -220,7 +220,7 @@ fn resolve_errors_when_conversation_is_not_loaded() { #[test] fn resolve_returns_conversation_missing_variant_when_id_not_in_index() { let tmp = camino_tempfile::tempdir().unwrap(); - let workspace = Workspace::new(tmp.path().to_path_buf()); + let workspace = Workspace::in_memory(tmp.path().to_path_buf()); let id = ConversationId::try_from_deciseconds(17_013_123_456).unwrap(); let uri = Url::parse(&format!("jp://{id}")).unwrap(); @@ -234,7 +234,7 @@ fn resolve_returns_conversation_missing_variant_when_id_not_in_index() { #[test] fn resolve_returns_other_for_invalid_selector() { let tmp = camino_tempfile::tempdir().unwrap(); - let workspace = Workspace::new(tmp.path().to_path_buf()); + let workspace = Workspace::in_memory(tmp.path().to_path_buf()); let id = ConversationId::try_from_deciseconds(17_013_123_456).unwrap(); let uri = Url::parse(&format!("jp://{id}?select=zzz")).unwrap(); diff --git a/crates/jp_cli/src/cmd.rs b/crates/jp_cli/src/cmd.rs index 56f63ceab..f120d5e5c 100644 --- a/crates/jp_cli/src/cmd.rs +++ b/crates/jp_cli/src/cmd.rs @@ -734,6 +734,11 @@ impl From for Error { ] .into(), MissingStorage => [("message", "Missing storage directory".into())].into(), + WorkspaceNotFound(path) => [ + ("message", "No workspace found".into()), + ("path", path.to_string().into()), + ] + .into(), LockFailed(id) => [( "message", format!("Failed to lock conversation {id}").into(), diff --git a/crates/jp_cli/src/cmd/attachment_tests.rs b/crates/jp_cli/src/cmd/attachment_tests.rs index 3f95d3f37..8bb5bdf3f 100644 --- a/crates/jp_cli/src/cmd/attachment_tests.rs +++ b/crates/jp_cli/src/cmd/attachment_tests.rs @@ -21,7 +21,7 @@ fn make_id(secs: u64) -> ConversationId { /// missing-conversation path. fn empty_ctx() -> (Ctx, Runtime) { let tmp = tempdir().unwrap(); - let workspace = Workspace::new(tmp.path().to_path_buf()); + let workspace = Workspace::in_memory(tmp.path().to_path_buf()); let (printer, _out, _err) = Printer::memory(OutputFormat::Text); let runtime = Runtime::new().unwrap(); @@ -45,7 +45,7 @@ fn empty_ctx() -> (Ctx, Runtime) { #[test] fn an_empty_listing_is_still_an_array_in_json() { let tmp = tempdir().unwrap(); - let workspace = Workspace::new(tmp.path().to_path_buf()); + let workspace = Workspace::in_memory(tmp.path().to_path_buf()); let (printer, out, _err) = Printer::memory(OutputFormat::Json); let mut ctx = Ctx::new( workspace, diff --git a/crates/jp_cli/src/cmd/config/set_tests.rs b/crates/jp_cli/src/cmd/config/set_tests.rs index 52b65a948..98dddc300 100644 --- a/crates/jp_cli/src/cmd/config/set_tests.rs +++ b/crates/jp_cli/src/cmd/config/set_tests.rs @@ -43,7 +43,7 @@ fn setup( .with_user_storage(&user, None, "abc") .unwrap(); let fs = Arc::new(fs); - let mut workspace = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); workspace.load_conversation_index(); for &id in conversation_ids { diff --git a/crates/jp_cli/src/cmd/conversation/archive_tests.rs b/crates/jp_cli/src/cmd/conversation/archive_tests.rs index aa4b8971d..06a4018c8 100644 --- a/crates/jp_cli/src/cmd/conversation/archive_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/archive_tests.rs @@ -25,7 +25,7 @@ fn test_session() -> Session { /// Build a workspace with a conversation that the session has activated. fn workspace_with_active_conversation(id: ConversationId) -> (Workspace, Session) { - let mut ws = Workspace::new("/tmp/jp-cli-archive-test"); + let mut ws = Workspace::in_memory("/tmp/jp-cli-archive-test"); ws.create_conversation_with_id(id, Conversation::default(), Arc::new(AppConfig::new_test())); let session = test_session(); @@ -206,7 +206,7 @@ fn make_conversation(last_activated_secs: i64) -> Conversation { } fn workspace_with(conversations: &[(ConversationId, Conversation)]) -> Workspace { - let mut ws = Workspace::new("/tmp/jp-cli-archive-resolve-test"); + let mut ws = Workspace::in_memory("/tmp/jp-cli-archive-resolve-test"); let config = Arc::new(AppConfig::new_test()); for (id, conv) in conversations { ws.create_conversation_with_id(*id, conv.clone(), config.clone()); diff --git a/crates/jp_cli/src/cmd/conversation/fork_tests.rs b/crates/jp_cli/src/cmd/conversation/fork_tests.rs index a2a1b4ac3..64b18b743 100644 --- a/crates/jp_cli/src/cmd/conversation/fork_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/fork_tests.rs @@ -1001,7 +1001,7 @@ fn test_conversation_fork() { .with_user_storage(&user, None, "abc") .unwrap(), ); - let workspace = Workspace::new(tmp.path()).with_backend(fs); + let workspace = Workspace::in_memory(tmp.path()).with_backend(fs); let mut ctx = Ctx::new( workspace, None, @@ -1097,7 +1097,7 @@ fn fork_reresolves_apply_on_fork_rules() { .with_user_storage(&user, None, "abc") .unwrap(), ); - let workspace = Workspace::new(tmp.path()).with_backend(fs); + let workspace = Workspace::in_memory(tmp.path()).with_backend(fs); let mut ctx = Ctx::new( workspace, None, @@ -1194,7 +1194,7 @@ fn a_failing_fork_rule_creates_no_conversation() { .with_user_storage(&user, None, "abc") .unwrap(), ); - let workspace = Workspace::new(tmp.path()).with_backend(fs); + let workspace = Workspace::in_memory(tmp.path()).with_backend(fs); let mut ctx = Ctx::new( workspace, None, @@ -1260,7 +1260,7 @@ fn fork_targets_correct_source() { .with_user_storage(&user, None, "abc") .unwrap(), ); - let workspace = Workspace::new(tmp.path()).with_backend(fs); + let workspace = Workspace::in_memory(tmp.path()).with_backend(fs); let mut ctx = Ctx::new( workspace, None, @@ -1393,7 +1393,7 @@ fn fork_inherits_local_only_projection() { .with_user_storage(&user, None, "abc") .unwrap(), ); - let workspace = Workspace::new(tmp.path()).with_backend(fs); + let workspace = Workspace::in_memory(tmp.path()).with_backend(fs); let mut ctx = Ctx::new( workspace, None, diff --git a/crates/jp_cli/src/cmd/conversation/grep_tests.rs b/crates/jp_cli/src/cmd/conversation/grep_tests.rs index 4c0d1c006..a0c5aff06 100644 --- a/crates/jp_cli/src/cmd/conversation/grep_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/grep_tests.rs @@ -81,7 +81,7 @@ fn setup_conversations_with( ) -> (Ctx, SharedBuffer) { let tmp = tempdir().unwrap(); let config = AppConfig::new_test(); - let workspace = Workspace::new(tmp.path()); + let workspace = Workspace::in_memory(tmp.path()); let (printer, out, _err) = Printer::memory(format); let printer = printer.with_output_width(width); let mut ctx = Ctx::new( diff --git a/crates/jp_cli/src/cmd/conversation/path_tests.rs b/crates/jp_cli/src/cmd/conversation/path_tests.rs index 0eea75d07..36e484ba0 100644 --- a/crates/jp_cli/src/cmd/conversation/path_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/path_tests.rs @@ -28,7 +28,7 @@ fn setup(id: ConversationId) -> (Ctx, SharedBuffer, Utf8TempDir) { fs.write_test_conversation(&id, &Conversation::default()); let config = AppConfig::new_test(); - let mut workspace = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); workspace.load_conversation_index(); let (printer, out, _err) = Printer::memory(OutputFormat::Text); diff --git a/crates/jp_cli/src/cmd/conversation/print_tests.rs b/crates/jp_cli/src/cmd/conversation/print_tests.rs index 0a449ff05..6dbb5c5e8 100644 --- a/crates/jp_cli/src/cmd/conversation/print_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/print_tests.rs @@ -47,7 +47,7 @@ fn setup_ctx_with_config( ) -> (Ctx, ConversationId, SharedBuffer, SharedBuffer, Runtime) { let tmp = tempdir().unwrap(); let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); - let workspace = Workspace::new(tmp.path()); + let workspace = Workspace::in_memory(tmp.path()); let runtime = Runtime::new().unwrap(); let mut ctx = Ctx::new( diff --git a/crates/jp_cli/src/cmd/conversation/rm_tests.rs b/crates/jp_cli/src/cmd/conversation/rm_tests.rs index 2cf2917d1..582076351 100644 --- a/crates/jp_cli/src/cmd/conversation/rm_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/rm_tests.rs @@ -26,7 +26,7 @@ fn empty_rm() -> Rm { } fn workspace_with_conversations(ids: &[ConversationId]) -> Workspace { - let mut ws = Workspace::new("/tmp/jp-cli-rm-test"); + let mut ws = Workspace::in_memory("/tmp/jp-cli-rm-test"); let config = Arc::new(AppConfig::new_test()); for id in ids { ws.create_conversation_with_id(*id, Conversation::default(), config.clone()); diff --git a/crates/jp_cli/src/cmd/conversation/use_tests.rs b/crates/jp_cli/src/cmd/conversation/use_tests.rs index 76599510c..432f2c4c3 100644 --- a/crates/jp_cli/src/cmd/conversation/use_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/use_tests.rs @@ -41,7 +41,7 @@ fn test_session() -> Session { /// conversation whose `last_activated_at` is pinned to /// `ORIGINAL_LAST_ACTIVATED`. fn setup(id: ConversationId) -> Ctx { - let mut workspace = Workspace::new("/tmp/jp-cli-use-test"); + let mut workspace = Workspace::in_memory("/tmp/jp-cli-use-test"); workspace.create_conversation_with_id( id, Conversation { @@ -152,7 +152,7 @@ fn run_with_contention_skips_metadata_bump() { // can be driven without the interactive picker. fn setup_multi(entries: Vec<(ConversationId, Conversation, Vec)>) -> Ctx { - let mut workspace = Workspace::new("/tmp/jp-cli-use-filter-test"); + let mut workspace = Workspace::in_memory("/tmp/jp-cli-use-filter-test"); let config = Arc::new(AppConfig::new_test()); for (id, conversation, _) in &entries { diff --git a/crates/jp_cli/src/cmd/init.rs b/crates/jp_cli/src/cmd/init.rs index 9bb33df29..809fee4ed 100644 --- a/crates/jp_cli/src/cmd/init.rs +++ b/crates/jp_cli/src/cmd/init.rs @@ -13,10 +13,10 @@ use jp_config::{ }; use jp_printer::Printer; use jp_storage::backend::FsStorageBackend; -use jp_workspace::Workspace; +use jp_workspace::{DEFAULT_STORAGE_DIR, Workspace}; use schematic::ConfigEnum as _; -use crate::{DEFAULT_STORAGE_DIR, cmd::Output, ctx::IntoPartialAppConfig}; +use crate::{cmd::Output, ctx::IntoPartialAppConfig}; #[derive(Debug, clap::Args)] pub(crate) struct Init { @@ -50,7 +50,7 @@ impl Init { let id = jp_workspace::Id::new(); let fs = Arc::new(FsStorageBackend::new(&storage)?); - let _workspace = Workspace::new_with_id(root.clone(), id.clone()).with_backend(fs); + let _workspace = Workspace::in_memory_with_id(root.clone(), id.clone()).with_backend(fs); id.store(&storage)?; diff --git a/crates/jp_cli/src/cmd/plugin/dispatch_tests.rs b/crates/jp_cli/src/cmd/plugin/dispatch_tests.rs index 7f14eaea5..be749c6d3 100644 --- a/crates/jp_cli/src/cmd/plugin/dispatch_tests.rs +++ b/crates/jp_cli/src/cmd/plugin/dispatch_tests.rs @@ -55,7 +55,7 @@ fn message_loop_ready_then_exit() { // We can't easily construct a Workspace for a unit test without a temp dir, // but this test only exercises ready + exit (no workspace queries). We // construct a minimal in-memory workspace. - let ws = jp_workspace::Workspace::new("/tmp/jp-test-plugin"); + let ws = jp_workspace::Workspace::in_memory("/tmp/jp-test-plugin"); message_loop(reader, &sink, &ws, &config, &shutdown_sent).unwrap(); } diff --git a/crates/jp_cli/src/cmd/query/stream/retry_tests.rs b/crates/jp_cli/src/cmd/query/stream/retry_tests.rs index 28f015a21..e5b573bf2 100644 --- a/crates/jp_cli/src/cmd/query/stream/retry_tests.rs +++ b/crates/jp_cli/src/cmd/query/stream/retry_tests.rs @@ -57,7 +57,7 @@ fn make_turn_coordinator_with_output() -> (TurnCoordinator, Arc, Shared /// Create a workspace with a single conversation and return a test lock. fn make_test_lock() -> (Workspace, ConversationLock) { let config = Arc::new(AppConfig::new_test()); - let mut workspace = Workspace::new(camino::Utf8PathBuf::new()); + let mut workspace = Workspace::in_memory(camino::Utf8PathBuf::new()); let id = workspace.create_conversation(Conversation::default(), config); let handle = workspace.acquire_conversation(&id).unwrap(); let lock = workspace.test_lock(handle); diff --git a/crates/jp_cli/src/cmd/query/turn_loop_tests.rs b/crates/jp_cli/src/cmd/query/turn_loop_tests.rs index 3fe664f9c..479aa8e49 100644 --- a/crates/jp_cli/src/cmd/query/turn_loop_tests.rs +++ b/crates/jp_cli/src/cmd/query/turn_loop_tests.rs @@ -326,7 +326,7 @@ async fn test_interrupt_stop_during_streaming_persists_content() { let config = AppConfig::new_test(); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -427,7 +427,7 @@ async fn test_streaming_interrupt_menu_cancel_escalates() { let config = AppConfig::new_test(); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -534,7 +534,7 @@ async fn test_normal_completion_persists_content() { let config = AppConfig::new_test(); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -616,7 +616,7 @@ async fn premature_stream_end_without_finished_returns_error() { config.assistant.request.max_retries = 0; let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -683,7 +683,7 @@ async fn premature_stream_end_exhausts_retry_budget() { config.assistant.request.base_backoff_ms = 0; let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -761,7 +761,7 @@ async fn output_ceiling_ends_turn_without_re_requesting() { config.assistant.request.stream_idle_timeout_secs = 0; let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -845,7 +845,7 @@ async fn orphan_tool_call_is_sanitized_before_provider_request() { let config = AppConfig::new_test(); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -945,7 +945,7 @@ async fn test_tool_call_cycle_completes_with_followup() { let config = AppConfig::new_test(); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -1203,7 +1203,7 @@ async fn test_tool_interrupt_menu_cancel_escalates() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -1351,7 +1351,7 @@ async fn test_tool_stop_on_interrupt_commits_responses_without_follow_up() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -1493,7 +1493,7 @@ async fn test_interrupt_during_tool_prompt_completes_turn_early() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -1610,7 +1610,7 @@ async fn test_multiple_tool_calls_in_sequence() { let config = AppConfig::new_test(); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -1720,7 +1720,7 @@ async fn test_empty_tool_response_continues_cycle() { let config = AppConfig::new_test(); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -1829,7 +1829,7 @@ async fn test_tool_restart_on_interrupt() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -1984,7 +1984,7 @@ async fn test_merged_stream_exits_after_tool_response() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -2096,7 +2096,7 @@ async fn test_tool_call_with_run_mode_ask_approves() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -2239,7 +2239,7 @@ async fn test_tool_call_with_run_mode_ask_skips() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -2389,7 +2389,7 @@ async fn test_tool_call_with_run_mode_unattended() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -2527,7 +2527,7 @@ async fn test_tool_call_with_run_mode_skip() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -2702,7 +2702,7 @@ async fn test_multiple_tools_with_different_run_modes() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -2889,7 +2889,7 @@ async fn test_tool_call_returns_error() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -3131,7 +3131,7 @@ async fn test_waiting_indicator_shows_during_delay() { config.style.streaming.progress.interval_ms = 100; let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -3221,7 +3221,7 @@ async fn test_waiting_indicator_survives_keep_alive_and_shows_status() { config.style.streaming.progress.interval_ms = 50; let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -3330,7 +3330,7 @@ async fn test_waiting_indicator_cleared_before_retry_notice() { config.assistant.request.base_backoff_ms = 1; let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -3431,7 +3431,7 @@ async fn test_waiting_indicator_not_shown_when_disabled() { config.style.streaming.progress.show = false; let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -3507,7 +3507,7 @@ async fn test_waiting_indicator_not_shown_for_non_tty() { config.style.streaming.progress.delay_secs = 0; let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -3589,7 +3589,7 @@ async fn test_multi_part_tool_call_shows_preparing_spinner() { config.style.tool_call.preparing.interval_ms = 50; let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -3776,7 +3776,7 @@ async fn test_turn_start_event_is_emitted() { let config = AppConfig::new_test(); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -3837,7 +3837,7 @@ async fn test_turn_start_index_increments_across_turns() { let config = AppConfig::new_test(); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -3941,7 +3941,7 @@ async fn test_markdown_flushed_before_tool_header() { config.style.tool_call.preparing.show = false; let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -4105,7 +4105,7 @@ async fn test_parallel_tool_calls_rendered_atomically() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -4277,7 +4277,7 @@ async fn test_single_tool_call_rendered_with_args() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -4675,7 +4675,7 @@ async fn test_tool_with_single_inquiry() { ); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -4808,7 +4808,7 @@ async fn test_tool_with_multiple_inquiries() { ); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -4953,7 +4953,7 @@ async fn test_parallel_tools_one_with_inquiry() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -5084,7 +5084,7 @@ async fn test_parallel_tools_both_with_inquiries() { .insert("tool_b".to_string(), inquiry_tool_config(&["confirm_b"])); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -5265,7 +5265,7 @@ async fn test_retry_counter_resets_on_successful_event() { config.assistant.request.max_backoff_secs = 1; let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -5385,7 +5385,7 @@ async fn test_unavailable_tool_before_approved_does_not_panic() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -5502,7 +5502,7 @@ async fn test_inquiry_failure_marks_tool_as_error() { ); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -5709,7 +5709,7 @@ async fn test_live_header_uses_configured_model_id_not_provider_returned() { let config = AppConfig::new_test(); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -5813,7 +5813,7 @@ async fn reasoning_before_a_tool_call_shades_the_tool_chrome() { }); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) .unwrap(); @@ -5957,7 +5957,7 @@ async fn test_rebuild_cap_stops_a_provider_that_keeps_requesting_rebuilds() { let config = AppConfig::new_test(); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) @@ -6029,7 +6029,7 @@ async fn test_refused_rebuild_clears_the_retry_line() { config.assistant.request.base_backoff_ms = 1; let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), Arc::new(config.clone()), None) @@ -6128,7 +6128,7 @@ async fn test_refused_rebuild_persists_streamed_content() { let config = AppConfig::new_test(); let fs = Arc::new(FsStorageBackend::new(&storage).expect("failed to create backend")); - let mut workspace = Workspace::new(root).with_backend(fs.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs.clone()); let lock = workspace .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index fee37da27..5f3efdf58 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -683,7 +683,7 @@ fn query_model_override_is_persisted_as_config_delta() { let base_config = Arc::new(config_with_model(ProviderId::Anthropic, "base-model")); let conversation_id = make_id(1000); - let mut workspace = Workspace::new("/tmp/test"); + let mut workspace = Workspace::in_memory("/tmp/test"); workspace.create_conversation_with_id( conversation_id, Conversation::default(), @@ -741,7 +741,7 @@ fn query_cfg_sourced_compaction_persists_as_config_delta() { let base_config = Arc::new(config_with_model(ProviderId::Anthropic, "base-model")); let conversation_id = make_id(2000); - let mut workspace = Workspace::new("/tmp/test"); + let mut workspace = Workspace::in_memory("/tmp/test"); workspace.create_conversation_with_id( conversation_id, Conversation::default(), @@ -819,7 +819,7 @@ async fn query_sequence_new_cfg_profile_then_model_override_persists_for_plain_q .into(), ); - let mut workspace = Workspace::new(root); + let mut workspace = Workspace::in_memory(root); let query1 = Query { new_conversation: true, @@ -963,7 +963,7 @@ fn apply_title_override_no_title_clears_existing_title() { // conversation inherits the source's title via // `fork_conversation`, and `--no-title` is supposed to leave // the run with no title at all. - let mut workspace = Workspace::new("/tmp/test"); + let mut workspace = Workspace::in_memory("/tmp/test"); let lock = lock_with_title(&mut workspace, make_id(1000), Some("inherited")); apply_title_override(&lock, None, true); @@ -976,7 +976,7 @@ fn apply_title_override_no_title_clears_resumed_title() { // `--no-title` is symmetric with `--title T`: both write the // user's intent into `metadata.title`, regardless of whether // the conversation is new, forked, or resumed. - let mut workspace = Workspace::new("/tmp/test"); + let mut workspace = Workspace::in_memory("/tmp/test"); let lock = lock_with_title(&mut workspace, make_id(1001), Some("existing")); apply_title_override(&lock, None, true); @@ -986,7 +986,7 @@ fn apply_title_override_no_title_clears_resumed_title() { #[test] fn apply_title_override_title_overwrites_existing_title() { - let mut workspace = Workspace::new("/tmp/test"); + let mut workspace = Workspace::in_memory("/tmp/test"); let lock = lock_with_title(&mut workspace, make_id(1002), Some("old")); apply_title_override(&lock, Some("new"), false); @@ -996,7 +996,7 @@ fn apply_title_override_title_overwrites_existing_title() { #[test] fn apply_title_override_neither_flag_is_noop() { - let mut workspace = Workspace::new("/tmp/test"); + let mut workspace = Workspace::in_memory("/tmp/test"); let lock = lock_with_title(&mut workspace, make_id(1003), Some("keep")); apply_title_override(&lock, None, false); @@ -1977,7 +1977,7 @@ fn run_missing_at_path_query_leaves_conversation_and_session_untouched() { }; let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let mut ctx = Ctx::new( - Workspace::new("/tmp/jp-cli-query-test"), + Workspace::in_memory("/tmp/jp-cli-query-test"), None, Runtime::new().unwrap(), Globals::default(), @@ -2016,7 +2016,7 @@ fn run_missing_at_path_query_leaves_conversation_and_session_untouched() { #[test] fn run_failing_alias_leaves_the_title_untouched() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); - let mut workspace = Workspace::new("/tmp/jp-cli-query-label-test"); + let mut workspace = Workspace::in_memory("/tmp/jp-cli-query-label-test"); let id = make_id(4242); workspace.create_conversation_with_id( id, diff --git a/crates/jp_cli/src/cmd/target_tests.rs b/crates/jp_cli/src/cmd/target_tests.rs index 6307f1567..f81acf597 100644 --- a/crates/jp_cli/src/cmd/target_tests.rs +++ b/crates/jp_cli/src/cmd/target_tests.rs @@ -12,7 +12,7 @@ use jp_workspace::{ use super::*; fn workspace_with_conversation() -> (Workspace, ConversationId) { - let mut ws = Workspace::new(Utf8PathBuf::new()); + let mut ws = Workspace::in_memory(Utf8PathBuf::new()); let config = Arc::new(AppConfig::new_test()); let id = ws.create_conversation(Conversation::default(), config); (ws, id) @@ -26,7 +26,7 @@ fn make_id(secs: u64) -> ConversationId { /// A workspace whose session activated `previous` and then `active`, so the two /// session-scoped keywords resolve to different conversations. fn workspace_with_session_history() -> (Workspace, Session, ConversationId, ConversationId) { - let mut ws = Workspace::new(Utf8PathBuf::new()); + let mut ws = Workspace::in_memory(Utf8PathBuf::new()); let config = Arc::new(AppConfig::new_test()); let previous = make_id(1000); let active = make_id(2000); @@ -74,7 +74,7 @@ fn last_created_resolves() { #[test] fn last_activated_empty_workspace_returns_none() { - let ws = Workspace::new(Utf8PathBuf::new()); + let ws = Workspace::in_memory(Utf8PathBuf::new()); assert_eq!( resolve_default_id(DefaultConversationId::LastActivated, &ws, None), None @@ -193,7 +193,7 @@ fn archived_keyword_errors_when_no_archived_conversations() { #[test] fn all_archived_empty_returns_error() { - let ws = Workspace::new(Utf8PathBuf::new()); + let ws = Workspace::in_memory(Utf8PathBuf::new()); let result = ConversationTarget::AllArchived.resolve(&ws, None); assert!(result.is_err()); } @@ -314,7 +314,7 @@ fn all_live_resolves_to_every_live_conversation() { #[test] fn all_live_empty_workspace_errors() { - let ws = Workspace::new(Utf8PathBuf::new()); + let ws = Workspace::in_memory(Utf8PathBuf::new()); assert!(ConversationTarget::AllLive.resolve(&ws, None).is_err()); } diff --git a/crates/jp_cli/src/lib.rs b/crates/jp_cli/src/lib.rs index cd0a93764..c0fd134cc 100644 --- a/crates/jp_cli/src/lib.rs +++ b/crates/jp_cli/src/lib.rs @@ -52,7 +52,7 @@ use jp_storage::backend::{ FsStorageBackend, NullLockBackend, NullPersistBackend, ReadOnlySessionBackend, }; use jp_term::table::{DetailRow, Details, details, details_markdown}; -use jp_workspace::{Workspace, user_data_dir}; +use jp_workspace::{DEFAULT_STORAGE_DIR, Workspace, user_data_dir}; use relative_path::RelativePath; use serde_json::Value; use tokio::runtime::{self, Runtime}; @@ -69,8 +69,6 @@ use crate::{ static WORKER_THREADS: AtomicUsize = AtomicUsize::new(0); -const DEFAULT_STORAGE_DIR: &str = ".jp"; - #[expect(dead_code)] const DEFAULT_VARIABLE_PREFIX: &str = "JP_"; @@ -926,48 +924,25 @@ fn load_workspace( .try_into() .map_err(FromPathBufError::into_io_error)?, }; - trace!(cwd = %cwd, "Finding workspace."); - - let root = Workspace::find_root(cwd, DEFAULT_STORAGE_DIR).ok_or(cmd::Error::from(format!( - "Could not locate workspace. Use `{}` to create a new workspace.", - "jp init".bold().yellow() - )))?; - trace!(root = %root, "Found workspace root."); - - let storage = root.join(DEFAULT_STORAGE_DIR); - trace!(storage = %storage, "Initializing workspace storage."); - - let id = jp_workspace::Id::load(&storage) - .transpose() - .ok() - .flatten() - .unwrap_or_default(); - - trace!(%id, "Loaded unique workspace ID."); - - let fs = FsStorageBackend::new(&storage).map_err(jp_workspace::Error::from)?; - - let user_root = user_data_dir()?.join("workspace"); - // The workspace directory name slugs a freshly created silo so users can - // recognize it; an existing silo is reused by ID regardless of its slug. - let slug = root.file_name(); - let fs = fs - .with_user_storage(&user_root, slug, id.to_string()) - .map_err(jp_workspace::Error::from)?; - - let fs = Arc::new(fs); - let mut workspace = Workspace::new_with_id(root, id).with_backend(fs.clone()); + let mut workspace = Workspace::open(&cwd).map_err(|error| match error { + jp_workspace::Error::WorkspaceNotFound(_) => Error::Command(cmd::Error::from(format!( + "Could not locate workspace. Use `{}` to create a new workspace.", + "jp init".bold().yellow() + ))), + error => Error::Workspace(error), + })?; + + let fs = workspace.fs_storage().cloned(); if !persist { + let sessions = Arc::new(ReadOnlySessionBackend::new(workspace.sessions().clone())); workspace = workspace .with_persist(Arc::new(NullPersistBackend)) .with_locker(Arc::new(NullLockBackend)) - .with_sessions(Arc::new(ReadOnlySessionBackend::new(fs.clone()))); + .with_sessions(sessions); } info!(workspace = %workspace.root(), "Using existing workspace."); - workspace.id().store(&storage)?; - - Ok((workspace, Some(fs))) + Ok((workspace, fs)) } const JP_CRATES: &[&str] = &[ diff --git a/crates/jp_cli/src/lib_tests.rs b/crates/jp_cli/src/lib_tests.rs index eb7df1b15..b5110db12 100644 --- a/crates/jp_cli/src/lib_tests.rs +++ b/crates/jp_cli/src/lib_tests.rs @@ -129,7 +129,7 @@ fn test_cli() { fn test_load_cli_cfg_args_workspace_root() { let tmp = tempdir().unwrap(); let root = tmp.path(); - let workspace = Workspace::new(root); + let workspace = Workspace::in_memory(root); write_config( &root.join(".jp/config/skill/web.toml"), @@ -174,7 +174,7 @@ fn test_load_cli_cfg_args_merges_global_and_workspace() { unsafe { std::env::set_var("JP_GLOBAL_CONFIG_DIR", global_dir.as_str()) }; - let workspace = Workspace::new(&ws_root); + let workspace = Workspace::in_memory(&ws_root); write_config( &global_dir.join("config/.jp/config/skill/web.toml"), @@ -208,7 +208,7 @@ fn test_load_cli_cfg_args_workspace_overrides_global() { unsafe { std::env::set_var("JP_GLOBAL_CONFIG_DIR", global_dir.as_str()) }; - let workspace = Workspace::new(&ws_root); + let workspace = Workspace::in_memory(&ws_root); write_config( &global_dir.join("config/.jp/config/skill/web.toml"), @@ -232,7 +232,7 @@ fn test_load_cli_cfg_args_workspace_overrides_global() { fn test_load_cli_cfg_args_missing_file_reports_searched_paths() { let tmp = tempdir().unwrap(); let root = tmp.path(); - let workspace = Workspace::new(root); + let workspace = Workspace::in_memory(root); let partial = partial_with_load_paths(&[".jp/config"]); let overrides = vec![KeyValueOrPath::Path(Utf8PathBuf::from("skill/missing"))]; @@ -256,7 +256,7 @@ fn test_load_cli_cfg_args_missing_file_reports_searched_paths() { fn test_load_cli_cfg_args_first_load_path_wins_within_root() { let tmp = tempdir().unwrap(); let root = tmp.path(); - let workspace = Workspace::new(root); + let workspace = Workspace::in_memory(root); write_config( &root.join("first/skill/web.toml"), @@ -373,7 +373,7 @@ fn test_load_cli_cfg_args_global_only_when_workspace_has_no_match() { unsafe { std::env::set_var("JP_GLOBAL_CONFIG_DIR", global_dir.as_str()) }; - let workspace = Workspace::new(&ws_root); + let workspace = Workspace::in_memory(&ws_root); write_config( &global_dir.join("config/.jp/config/skill/web.toml"), @@ -411,7 +411,7 @@ fn query_model_override_persists_config_delta_through_run_inner() { env::set_current_dir(root).unwrap(); let fs_backend = Arc::new(FsStorageBackend::new(&storage).unwrap()); - let mut workspace = Workspace::new(root).with_backend(fs_backend.clone()); + let mut workspace = Workspace::in_memory(root).with_backend(fs_backend.clone()); let conversation_id = make_id(1000); let base_config = Arc::new(config_with_model(ProviderId::Anthropic, "opus")); @@ -525,7 +525,7 @@ fn query_model_override_persists_config_delta_through_session_targeting() { unsafe { env::remove_var("EDITOR") }; env::set_current_dir(root).unwrap(); - let mut workspace = Workspace::new(root); + let mut workspace = Workspace::in_memory(root); let user_root = user_data_dir().unwrap().join("workspace"); let fs_backend = Arc::new( FsStorageBackend::new(&storage) @@ -640,7 +640,7 @@ fn resolve_config_consumes_default_id() { let tmp = tempdir().unwrap(); let root = tmp.path(); - let mut workspace = Workspace::new(root); + let mut workspace = Workspace::in_memory(root); workspace.load_conversation_index(); // Inject default_id into the base partial — no filesystem needed. @@ -678,7 +678,7 @@ fn resolve_config_applies_the_compact_model_flag() { let storage = root.join(".jp"); let fs_backend = Arc::new(FsStorageBackend::new(&storage).unwrap()); - let mut workspace = Workspace::new(root).with_backend(fs_backend); + let mut workspace = Workspace::in_memory(root).with_backend(fs_backend); let conversation_id = make_id(3000); workspace .create_and_lock_conversation_with_id( diff --git a/crates/jp_cli/src/shared/search_tests.rs b/crates/jp_cli/src/shared/search_tests.rs index 71e448084..483d6892e 100644 --- a/crates/jp_cli/src/shared/search_tests.rs +++ b/crates/jp_cli/src/shared/search_tests.rs @@ -27,7 +27,7 @@ fn setup_ctx_with_conversations( ) -> Ctx { let tmp = tempdir().unwrap(); let config = AppConfig::new_test(); - let workspace = Workspace::new(tmp.path()); + let workspace = Workspace::in_memory(tmp.path()); let (printer, _, _) = Printer::memory(OutputFormat::TextPretty); let mut ctx = Ctx::new( workspace, diff --git a/crates/jp_workspace/src/error.rs b/crates/jp_workspace/src/error.rs index 767ed3c59..27e8844cc 100644 --- a/crates/jp_workspace/src/error.rs +++ b/crates/jp_workspace/src/error.rs @@ -19,6 +19,9 @@ pub enum Error { #[error("Cannot persist workspace without storage")] MissingStorage, + #[error("No workspace found at or above: {0}")] + WorkspaceNotFound(Utf8PathBuf), + #[error("Failed to acquire lock on conversation {0}")] LockFailed(String), diff --git a/crates/jp_workspace/src/lib.rs b/crates/jp_workspace/src/lib.rs index ee7c39f40..def0c1cac 100644 --- a/crates/jp_workspace/src/lib.rs +++ b/crates/jp_workspace/src/lib.rs @@ -28,8 +28,8 @@ use jp_config::AppConfig; use jp_conversation::{Conversation, ConversationId, ConversationStream}; use jp_storage::{ backend::{ - ConversationFilter, ConversationIndexEntry, InMemoryStorageBackend, LoadBackend, - LockBackend, NullPersistBackend, PersistBackend, Projection, SessionBackend, + ConversationFilter, ConversationIndexEntry, FsStorageBackend, InMemoryStorageBackend, + LoadBackend, LockBackend, NullPersistBackend, PersistBackend, Projection, SessionBackend, StoragePresence, }, lock::LockInfo, @@ -43,6 +43,10 @@ use crate::session::Session; const APPLICATION: &str = "jp"; +/// The directory a workspace stores its data in, relative to the workspace +/// root. +pub const DEFAULT_STORAGE_DIR: &str = ".jp"; + #[derive(Debug)] pub struct Workspace { /// The root directory of the workspace. @@ -63,6 +67,9 @@ pub struct Workspace { /// Backend for session-to-conversation mapping storage. sessions: Arc, + /// The filesystem backend, for workspaces opened from disk. + fs: Option>, + /// The in-memory state of the workspace. state: State, } @@ -87,23 +94,25 @@ impl Workspace { } } - /// Creates a new workspace with the given root directory. + /// Creates a workspace with the given root directory, backed by memory. /// - /// The workspace starts with in-memory backends (no filesystem - /// persistence). - /// Call [`with_backend`] to wire in a storage backend. + /// Nothing is read from or written to disk. + /// Call [`with_backend`] to wire in a storage backend, or [`open`] to open + /// a workspace that already exists on disk. /// + /// [`open`]: Self::open /// [`with_backend`]: Self::with_backend - pub fn new(root: impl Into) -> Self { - Self::new_with_id(root, id::Id::new()) + pub fn in_memory(root: impl Into) -> Self { + Self::in_memory_with_id(root, id::Id::new()) } - /// Creates a new workspace with the given root directory and ID. + /// Creates a workspace with the given root directory and ID, backed by + /// memory. /// /// All four backend slots are wired to a single shared /// [`InMemoryStorageBackend`], so data written through one trait is visible /// through the others. - pub fn new_with_id(root: impl Into, id: id::Id) -> Self { + pub fn in_memory_with_id(root: impl Into, id: id::Id) -> Self { let root = root.into(); trace!(root = %root, id = %id, "Initializing Workspace."); @@ -115,10 +124,71 @@ impl Workspace { loader: backend.clone(), locker: backend.clone(), sessions: backend, + fs: None, state: State::default(), } } + /// Open the workspace containing `dir`, wiring filesystem and user-local + /// storage. + /// + /// Walks up from `dir` until a [`DEFAULT_STORAGE_DIR`] directory is found, + /// and wires both that store and the workspace's user-local silo under + /// [`user_data_dir`]. + /// Conversations live in either root, so both are needed to see all of + /// them. + /// + /// Opening writes to disk: the user-local silo is created if missing, its + /// `storage` symlink is repointed at this workspace root, and the workspace + /// ID is persisted back to the store. + /// A store with no readable ID file is assigned a fresh ID. + /// + /// Returns [`Error::WorkspaceNotFound`] when neither `dir` nor any of its + /// parents holds a store. + pub fn open(dir: &Utf8Path) -> Result { + Self::open_with_storage_dir(dir, DEFAULT_STORAGE_DIR) + } + + /// Open the workspace containing `dir`, looking for a store named + /// `storage_dir`. + /// + /// Behaves exactly like [`open`], which uses [`DEFAULT_STORAGE_DIR`]. + /// + /// [`open`]: Self::open + pub fn open_with_storage_dir(dir: &Utf8Path, storage_dir: &str) -> Result { + trace!(dir = %dir, storage_dir, "Finding workspace."); + let root = Self::find_root(dir.to_path_buf(), storage_dir) + .ok_or_else(|| Error::WorkspaceNotFound(dir.to_path_buf()))?; + trace!(root = %root, "Found workspace root."); + + let storage = root.join(storage_dir); + trace!(storage = %storage, "Initializing workspace storage."); + + let id = Id::load(&storage) + .transpose() + .ok() + .flatten() + .unwrap_or_default(); + trace!(%id, "Loaded unique workspace ID."); + + let user_root = user_data_dir()?.join("workspace"); + // The workspace directory name slugs a freshly created silo so users can + // recognize it; an existing silo is reused by ID regardless of its slug. + let slug = root.file_name(); + let fs = Arc::new(FsStorageBackend::new(&storage)?.with_user_storage( + &user_root, + slug, + id.to_string(), + )?); + + let mut workspace = Self::in_memory_with_id(root, id).with_backend(fs.clone()); + workspace.fs = Some(fs); + + workspace.id().store(&storage)?; + + Ok(workspace) + } + /// Get the root path of the workspace. #[must_use] pub fn root(&self) -> &Utf8Path { @@ -153,6 +223,31 @@ impl Workspace { self } + /// The filesystem storage backend, for workspaces opened from disk. + /// + /// `None` for workspaces built with [`in_memory`], including those that had + /// a filesystem backend wired in through [`with_backend`]. + /// + /// [`in_memory`]: Self::in_memory + /// [`with_backend`]: Self::with_backend + #[must_use] + pub fn fs_storage(&self) -> Option<&Arc> { + self.fs.as_ref() + } + + /// The backend session-to-conversation mappings are read from and written + /// to. + /// + /// Set by [`with_sessions`] or [`with_backend`]; an in-memory workspace + /// starts with one that discards writes. + /// + /// [`with_backend`]: Self::with_backend + /// [`with_sessions`]: Self::with_sessions + #[must_use] + pub fn sessions(&self) -> &Arc { + &self.sessions + } + /// Set all four backends from a single implementation. /// /// Convenience for types that implement all four backend traits. diff --git a/crates/jp_workspace/src/lib_tests.rs b/crates/jp_workspace/src/lib_tests.rs index b7d1c4042..89559f1ee 100644 --- a/crates/jp_workspace/src/lib_tests.rs +++ b/crates/jp_workspace/src/lib_tests.rs @@ -21,12 +21,12 @@ use super::*; /// Test helper: wire a single backend into all four Workspace slots. fn workspace_with_fs(root: impl Into, fs: &FsStorageBackend) -> Workspace { - Workspace::new(root).with_backend(Arc::new(fs.clone())) + Workspace::in_memory(root).with_backend(Arc::new(fs.clone())) } #[test] fn conversation_presence_reflects_creation_intent() { - let mut ws = Workspace::new("root"); + let mut ws = Workspace::in_memory("root"); let config = Arc::new(AppConfig::new_test()); let projected = ConversationId::try_from(datetime!(2024-07-01 00:00:00 Z)).unwrap(); @@ -60,7 +60,7 @@ fn conversation_presence_reflects_creation_intent() { #[test] fn lock_projection_follows_presence() { - let mut ws = Workspace::new("root"); + let mut ws = Workspace::in_memory("root"); let config = Arc::new(AppConfig::new_test()); let local_id = ConversationId::try_from(datetime!(2024-08-01 00:00:00 Z)).unwrap(); @@ -212,7 +212,7 @@ fn test_workspace_persist_via_lock() { #[test] fn test_workspace_conversations() { - let mut workspace = Workspace::new(Utf8PathBuf::new()); + let mut workspace = Workspace::in_memory(Utf8PathBuf::new()); assert_eq!(workspace.conversations().count(), 0); let id = ConversationId::default(); @@ -229,7 +229,7 @@ fn test_workspace_conversations() { #[test] fn test_workspace_acquire_conversation() { - let mut workspace = Workspace::new(Utf8PathBuf::new()); + let mut workspace = Workspace::in_memory(Utf8PathBuf::new()); assert!(workspace.state.conversations.is_empty()); let id = ConversationId::try_from(chrono::Utc::now() - Duration::from_secs(1)).unwrap(); @@ -250,7 +250,7 @@ fn test_workspace_acquire_conversation() { #[test] fn test_workspace_create_conversation() { - let mut workspace = Workspace::new(Utf8PathBuf::new()); + let mut workspace = Workspace::in_memory(Utf8PathBuf::new()); assert!(workspace.state.conversations.is_empty()); let conversation = Conversation::default(); @@ -270,7 +270,7 @@ fn test_workspace_create_conversation() { #[test] fn test_workspace_remove_conversation() { - let mut workspace = Workspace::new(Utf8PathBuf::new()); + let mut workspace = Workspace::in_memory(Utf8PathBuf::new()); assert!(workspace.state.conversations.is_empty()); let id = ConversationId::try_from(chrono::Utc::now() - Duration::from_secs(1)).unwrap(); @@ -620,7 +620,7 @@ fn test_no_persist_skips_locking() { let fs = Arc::new(FsStorageBackend::new(&storage).unwrap()); // Simulate --no-persist: load from FS, but use null persist + null lock. - let mut workspace = Workspace::new(&root) + let mut workspace = Workspace::in_memory(&root) .with_loader(fs.clone() as Arc) .with_sessions(fs as Arc) .with_persist(Arc::new(NullPersistBackend)) @@ -645,7 +645,7 @@ fn test_no_persist_skips_locking() { /// denies the lock (instead of silently falling back to `NoopLockGuard`). #[test] fn test_lock_new_conversation_errors_on_denial() { - let mut workspace = Workspace::new("root"); + let mut workspace = Workspace::in_memory("root"); let config = Arc::new(AppConfig::new_test()); // Create a conversation and lock it via the in-memory backend. @@ -857,7 +857,7 @@ fn test_unarchive_clears_archived_at() { #[test] fn test_archived_conversations_returns_empty_when_none() { - let ws = Workspace::new(Utf8PathBuf::new()); + let ws = Workspace::in_memory(Utf8PathBuf::new()); assert_eq!(ws.archived_conversations().count(), 0); } @@ -916,6 +916,120 @@ fn test_unarchive_nonexistent_returns_error() { assert!(ws.unarchive_conversation(&id).is_err()); } +/// Conversations that live only in the user-local silo must be listed by a +/// workspace opened from disk. +/// +/// Wiring the filesystem backend without user-local storage still compiles, +/// still returns conversations, and raises no error — it just returns a +/// subset. +/// This assertion is the only thing standing between that mistake and a silent +/// data-visibility bug. +#[test] +#[serial(env_vars)] +fn open_lists_conversations_that_exist_only_in_user_local_storage() { + let _guard = UserDataDirEnvGuard::capture(); + let tmp = tempdir().unwrap(); + let user_data = tmp.path().join("user-data"); + + // SAFETY: mutating the environment races with any concurrent reader in the + // process. `#[serial(env_vars)]` keeps every test that touches these + // variables from running alongside this one, and the guard restores them. + unsafe { + env::set_var("JP_USER_DATA_DIR", user_data.as_str()); + env::remove_var("XDG_DATA_HOME"); + } + + let root = tmp.path().join("my-workspace"); + let storage = root.join(DEFAULT_STORAGE_DIR); + fs::create_dir_all(&storage).unwrap(); + let workspace_id: Id = "abcde".parse().unwrap(); + workspace_id.store(&storage).unwrap(); + + // Seed a `--local` conversation, which is written to the user-local silo + // and deliberately not projected into the workspace store. + let fs_backend = FsStorageBackend::new(&storage) + .unwrap() + .with_user_storage( + &user_data.join("workspace"), + root.file_name(), + workspace_id.to_string(), + ) + .unwrap(); + let local_id = ConversationId::try_from(datetime!(2024-09-01 00:00:00 Z)).unwrap(); + let mut seeded = workspace_with_fs(&root, &fs_backend); + seeded.create_conversation_with_projection( + local_id, + Conversation::default(), + Arc::new(AppConfig::new_test()), + Projection::LocalOnly, + ); + let handle = seeded.acquire_conversation(&local_id).unwrap(); + let mut conv = seeded.test_lock(handle).into_mut(); + conv.update_metadata(|_| {}); + conv.flush().unwrap(); + drop(conv); + drop(seeded); + + assert!( + !fs_backend + .build_conversation_dir(&local_id, None, false) + .exists(), + "the seeded conversation must exist in user-local storage only" + ); + + let mut opened = Workspace::open(&root).unwrap(); + opened.load_conversation_index(); + + let ids: Vec<_> = opened.conversations().map(|(id, _)| *id).collect(); + assert_eq!(ids, vec![local_id]); + assert_eq!(opened.id(), &workspace_id); + assert!(opened.fs_storage().is_some()); +} + +/// Opening any directory inside a workspace opens that workspace, matching how +/// the CLI resolves a workspace from the current directory. +#[test] +#[serial(env_vars)] +fn open_walks_up_from_a_nested_directory() { + let _guard = UserDataDirEnvGuard::capture(); + let tmp = tempdir().unwrap(); + + // SAFETY: as above — `#[serial(env_vars)]` serializes every test that + // touches these variables, and the guard restores them. + unsafe { + env::set_var("JP_USER_DATA_DIR", tmp.path().join("user-data").as_str()); + env::remove_var("XDG_DATA_HOME"); + } + + let root = tmp.path().join("my-workspace"); + fs::create_dir_all(root.join(DEFAULT_STORAGE_DIR)).unwrap(); + let nested = root.join("src/deeply/nested"); + fs::create_dir_all(&nested).unwrap(); + + let opened = Workspace::open(&nested).unwrap(); + + assert_eq!(opened.root(), root); +} + +// A store name that cannot exist keeps the assertion independent of whatever +// lives above the temp directory on the machine running the test. +#[test] +fn open_errors_when_no_store_exists_above_dir() { + let tmp = tempdir().unwrap(); + let dir = tmp.path().join("not-a-workspace"); + fs::create_dir_all(&dir).unwrap(); + + assert_eq!( + Workspace::open_with_storage_dir(&dir, ".jp-no-such-store").unwrap_err(), + Error::WorkspaceNotFound(dir) + ); +} + +#[test] +fn in_memory_workspace_has_no_fs_storage() { + assert!(Workspace::in_memory("root").fs_storage().is_none()); +} + /// Snapshot the two env vars [`user_data_dir`] depends on, so each test can /// freely mutate them and put the process state back the way it found it. struct UserDataDirEnvGuard { diff --git a/crates/jp_workspace/src/sanitize_tests.rs b/crates/jp_workspace/src/sanitize_tests.rs index d1c884635..13f9c1247 100644 --- a/crates/jp_workspace/src/sanitize_tests.rs +++ b/crates/jp_workspace/src/sanitize_tests.rs @@ -14,7 +14,7 @@ fn setup() -> (Utf8TempDir, Arc, Workspace) { let tmp = tempdir().unwrap(); let storage_path = tmp.path().join("storage"); let fs = Arc::new(FsStorageBackend::new(&storage_path).unwrap()); - let mut ws = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); ws.disable_persistence(); (tmp, fs, ws) } @@ -148,7 +148,7 @@ fn test_skips_dot_prefixed_directories() { fn test_no_storage_returns_empty_report() { // Without filesystem storage, sanitize returns an empty report // (InMemoryStorageBackend has nothing to sanitize). - let mut ws = Workspace::new("/nonexistent"); + let mut ws = Workspace::in_memory("/nonexistent"); let report = ws.sanitize().unwrap(); assert!(!report.has_repairs()); } diff --git a/crates/jp_workspace/src/session_mapping_tests.rs b/crates/jp_workspace/src/session_mapping_tests.rs index 7b6fd2bee..aafcb8a06 100644 --- a/crates/jp_workspace/src/session_mapping_tests.rs +++ b/crates/jp_workspace/src/session_mapping_tests.rs @@ -45,7 +45,7 @@ fn setup() -> (Utf8TempDir, Workspace, Option>) { .with_user_storage(&user_root, None, "abc") .unwrap(), ); - let mut ws = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); ws.disable_persistence(); (tmp, ws, Some(fs)) @@ -198,7 +198,7 @@ fn no_user_storage_returns_none() { // Workspace without user storage. let fs = Arc::new(FsStorageBackend::new(&storage_path).unwrap()); - let mut ws = Workspace::new(tmp.path()).with_backend(fs); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs); ws.disable_persistence(); let session = test_session(); @@ -211,7 +211,7 @@ fn no_user_storage_returns_error_on_write() { let storage_path = tmp.path().join("storage"); let fs = Arc::new(FsStorageBackend::new(&storage_path).unwrap()); - let mut ws = Workspace::new(tmp.path()).with_backend(fs); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs); ws.disable_persistence(); let session = test_session(); @@ -437,7 +437,7 @@ fn cleanup_keeps_session_referencing_conversation_created_after_index_load() { .with_user_storage(&user_root, None, "abc") .unwrap(), ); - let mut ws = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); ws.disable_persistence(); let fs = Some(fs); @@ -492,7 +492,7 @@ fn cleanup_keeps_env_session_with_live_conversations() { .with_user_storage(&user_root, None, "abc") .unwrap(), ); - let mut ws = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); ws.disable_persistence(); let fs = Some(fs); @@ -645,7 +645,7 @@ fn cleanup_keeps_archived_conversations_in_session_history() { .with_user_storage(&user_root, None, "abc") .unwrap(), ); - let mut ws = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); ws.disable_persistence(); let session = Session { @@ -684,7 +684,7 @@ fn cleanup_reads_lock_state_from_the_filesystem_not_the_workspace_backend() { .with_user_storage(&user_root, None, "abc") .unwrap(), ); - let mut ws = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); ws.disable_persistence(); ws = ws.with_locker(Arc::new(NullLockBackend)); @@ -725,7 +725,7 @@ fn cleanup_skips_session_maintenance_when_session_storage_is_read_only() { .with_user_storage(&user_root, None, "abc") .unwrap(), ); - let mut ws = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); ws.disable_persistence(); ws = ws.with_sessions(Arc::new(ReadOnlySessionBackend::new(fs.clone()))); @@ -775,7 +775,7 @@ fn ephemeral_cleanup_protects_the_conversation_the_session_resolves_to() { ); // Persistence stays enabled: the removal under test has to reach the disk, // otherwise the assertion below holds no matter which ids are protected. - let mut ws = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); let session = test_session(); let expired = ConversationId::try_from(datetime!(2025-07-19 14:00:00 Z)).unwrap(); @@ -825,7 +825,7 @@ fn ephemeral_cleanup_protects_a_conversation_created_after_the_index_was_loaded( ); // Persistence stays enabled: the removal under test has to reach the disk, // otherwise the assertion below holds no matter which ids are protected. - let mut ws = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); ws.load_conversation_index(); // Another process creates an expires-immediately conversation and records @@ -902,7 +902,7 @@ fn cleanup_skips_pruning_locked_conversations() { .with_user_storage(&user_root, None, "abc") .unwrap(), ); - let mut ws = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); ws.disable_persistence(); let session = Session { @@ -953,7 +953,7 @@ fn cleanup_prunes_dead_entries_from_session_history() { .with_user_storage(&user_root, None, "abc") .unwrap(), ); - let mut ws = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); ws.disable_persistence(); let session = Session { @@ -1094,7 +1094,7 @@ fn cleanup_migrates_legacy_filename_to_source_prefixed_key() { .with_user_storage(&user_root, None, "abc") .unwrap(), ); - let mut ws = Workspace::new(tmp.path()).with_backend(fs.clone()); + let mut ws = Workspace::in_memory(tmp.path()).with_backend(fs.clone()); ws.disable_persistence(); let session = Session { From 7bab44d0c331d8fc04631ed2a416634c655961ca Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Wed, 19 Aug 2026 07:52:19 +0200 Subject: [PATCH 2/8] feat(conversation): Expose events in a form readers outside Rust use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reader outside this workspace — the FFI boundary, a plugin, a web view — needs two things the crate did not offer. It needs timestamps in a format its platform can parse, and it needs to know what an entry in the stream is without decoding the storage encoding itself. `rfc3339` formats a timestamp the one way every platform's date parser accepts. Storage keeps timestamps in `time`'s human-readable spelling (`2024-09-01 10:00:00.0`), which nothing outside Rust reads, and each reader otherwise reinvents the conversion. Sub-second precision is kept when the value has any. `rfc3339_str` does the same for a caller holding raw JSON rather than a typed event, returning `None` for a value that parses as neither spelling so the caller can leave it as it found it. `EventKind::type_tag` returns the tag serde writes, which is what a reader switches on. `as_str` returns the Rust name and is for messages addressed to somebody reading this code; the two were the same string by coincidence and nothing held them that way. Two tests do now: one checks every variant's tag against what serde actually serializes, the other against `TYPE_TAGS`, the list the deserializer uses to decide whether an entry is a known event. A tag missing from that list makes its variant unreachable, and the stream keeps every such event as raw JSON instead. `StreamEntry` and `ConversationStream::iter_entries` give a borrowed view of every entry in order, including the config deltas, compaction overlays and unrecognized entries that `iter` and `iter_events_by_turn` skip. Those are part of what happened, and a reader presenting the stream to somebody wants them. Nothing is copied or re-encoded on the way out. Signed-off-by: Jean Mertz --- crates/jp_conversation/src/event.rs | 23 ++++++++++ crates/jp_conversation/src/event_tests.rs | 55 +++++++++++++++++++++++ crates/jp_conversation/src/lib.rs | 6 +-- crates/jp_conversation/src/storage.rs | 33 +++++++++++++- crates/jp_conversation/src/stream.rs | 45 +++++++++++++++++++ 5 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 crates/jp_conversation/src/event_tests.rs diff --git a/crates/jp_conversation/src/event.rs b/crates/jp_conversation/src/event.rs index 4ed0ff350..f4a25f875 100644 --- a/crates/jp_conversation/src/event.rs +++ b/crates/jp_conversation/src/event.rs @@ -418,6 +418,25 @@ impl EventKind { "inquiry_response", ]; + /// The `type` tag this variant serializes as. + /// + /// The serde spelling rather than the Rust name, because this is what a + /// reader outside Rust sees on the wire and switches on. + /// [`Self::as_str`] gives the Rust name, for a message addressed to + /// somebody reading this code. + #[must_use] + pub const fn type_tag(&self) -> &'static str { + match self { + Self::TurnStart(_) => "turn_start", + Self::ChatRequest(_) => "chat_request", + Self::ChatResponse(_) => "chat_response", + Self::ToolCallRequest(_) => "tool_call_request", + Self::ToolCallResponse(_) => "tool_call_response", + Self::InquiryRequest(_) => "inquiry_request", + Self::InquiryResponse(_) => "inquiry_response", + } + } + /// Returns the name of the event kind. #[must_use] pub const fn as_str(&self) -> &str { @@ -534,3 +553,7 @@ impl From for ConversationEvent { Self::now(turn_start) } } + +#[cfg(test)] +#[path = "event_tests.rs"] +mod tests; diff --git a/crates/jp_conversation/src/event_tests.rs b/crates/jp_conversation/src/event_tests.rs new file mode 100644 index 000000000..7f2004484 --- /dev/null +++ b/crates/jp_conversation/src/event_tests.rs @@ -0,0 +1,55 @@ +use serde_json::{Map, Value}; + +use super::{ + ChatRequest, ChatResponse, EventKind, InquiryId, InquiryQuestion, InquiryRequest, + InquiryResponse, InquirySource, ToolCallRequest, ToolCallResponse, TurnStart, +}; + +/// One value of every variant, so a new variant fails to compile here rather +/// than going unnoticed. +fn every_kind() -> Vec { + vec![ + TurnStart.into(), + ChatRequest::from("hi").into(), + ChatResponse::message("hello").into(), + ToolCallRequest::new("call-1".to_owned(), "read_file".to_owned(), Map::new()).into(), + ToolCallResponse { + id: "call-1".to_owned(), + result: Ok("contents".to_owned()), + } + .into(), + InquiryRequest::new( + InquiryId::new("q1"), + InquirySource::User, + InquiryQuestion::text("Which file?".to_owned()), + ) + .into(), + InquiryResponse::new(InquiryId::new("q1"), Value::Null).into(), + ] +} + +/// The tag a variant serializes as is what every reader outside Rust switches +/// on, so it has to be the tag serde actually writes rather than a name kept +/// alongside it by hand. +#[test] +fn every_variants_tag_is_the_one_serde_writes() { + for kind in every_kind() { + let serialized = serde_json::to_value(&kind).expect("serializes"); + let written = serialized + .get("type") + .and_then(Value::as_str) + .expect("carries a type tag"); + + assert_eq!(kind.type_tag(), written, "for {}", kind.as_str()); + } +} + +/// The deserializer decides whether an entry is a known event by looking its +/// tag up in this list, so a tag missing from it makes the variant unreachable: +/// the stream would keep every one of those events as raw JSON instead. +#[test] +fn every_variants_tag_is_listed_as_recognized() { + let tags: Vec<&str> = every_kind().iter().map(EventKind::type_tag).collect(); + + assert_eq!(tags, EventKind::TYPE_TAGS); +} diff --git a/crates/jp_conversation/src/lib.rs b/crates/jp_conversation/src/lib.rs index d5d82f296..651fe0637 100644 --- a/crates/jp_conversation/src/lib.rs +++ b/crates/jp_conversation/src/lib.rs @@ -43,8 +43,8 @@ pub use compaction::{ pub use conversation::{Conversation, ConversationId}; pub use error::Error; pub use event::{ConversationEvent, EventKind}; -pub use storage::decode_event_value; -pub use stream::{ConversationStream, IterTurns, StreamError, Turn, TurnMut}; +pub use storage::{decode_event_value, rfc3339, rfc3339_str}; +pub use stream::{ConversationStream, IterTurns, StreamEntry, StreamError, Turn, TurnMut}; /// A wrapper around `DateTime` that implements `Debug` to match `time`'s /// `OffsetDateTime` format (e.g. `2020-01-01 0:00:00.0 +00`). @@ -88,7 +88,7 @@ fn fmt_dt(dt: &chrono::DateTime) -> String { } /// Parse from `time`'s format or RFC 3339. -fn parse_dt(s: &str) -> Result, String> { +pub(crate) fn parse_dt(s: &str) -> Result, String> { chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") .map(|dt| dt.and_utc()) .or_else(|_| { diff --git a/crates/jp_conversation/src/storage.rs b/crates/jp_conversation/src/storage.rs index 6873f942e..742a7045f 100644 --- a/crates/jp_conversation/src/storage.rs +++ b/crates/jp_conversation/src/storage.rs @@ -9,9 +9,40 @@ //! The inner event types serialize as plain text. use base64::{Engine as _, engine::general_purpose::STANDARD}; +use chrono::{DateTime, SecondsFormat, Utc}; use serde_json::{Map, Value}; -use crate::event::EventKind; +use crate::{event::EventKind, parse_dt}; + +/// A timestamp in the one format events cross a boundary in. +/// +/// Storage keeps timestamps in `time`'s human-readable format (`2024-09-01 +/// 10:00:00.0`), which nothing outside Rust parses. +/// A reader on the far side of a boundary wants one format, and RFC 3339 is the +/// one every platform's date parser accepts. +/// +/// Sub-second precision is kept when the value has any and omitted when it does +/// not, which is what `AutoSi` means: a timestamp stored with a fractional part +/// keeps it. +#[must_use] +pub fn rfc3339(timestamp: DateTime) -> String { + timestamp.to_rfc3339_opts(SecondsFormat::AutoSi, true) +} + +/// A stored timestamp, re-spelled as RFC 3339. +/// +/// `None` for a value that parses as neither storage's format nor RFC 3339, +/// which a caller should leave as it found it: a timestamp nobody can read is +/// still better than no field at all. +/// +/// For a caller holding raw JSON rather than a typed event — an entry written +/// by a newer build, kept verbatim, whose timestamp still has to reach a reader +/// in the same format as every other one. +/// A caller holding the typed value calls [`rfc3339`] and parses nothing. +#[must_use] +pub fn rfc3339_str(timestamp: &str) -> Option { + parse_dt(timestamp).ok().map(rfc3339) +} /// Which encoding to apply to a given field. enum Field { diff --git a/crates/jp_conversation/src/stream.rs b/crates/jp_conversation/src/stream.rs index beec86bc8..5f99d41d5 100644 --- a/crates/jp_conversation/src/stream.rs +++ b/crates/jp_conversation/src/stream.rs @@ -153,6 +153,30 @@ impl InternalEvent { } } +/// One entry in a conversation stream, borrowed. +/// +/// The stream holds more than conversation events, and a reader presenting it +/// to somebody needs to see all of it. +/// This is that view: what an entry is, without exposing the storage encoding +/// [`InternalEvent`] carries. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum StreamEntry<'a> { + /// An event in the conversation. + Event(&'a ConversationEvent), + + /// A change to the configuration every later entry is bound to. + ConfigDelta(&'a ConfigDelta), + + /// An overlay changing how a range of earlier turns is projected. + Compaction(&'a Compaction), + + /// An entry whose `type` tag this build does not recognize. + /// + /// Kept verbatim so it round-trips, and readable only as JSON: there is no + /// typed form of an entry this build has never heard of. + Unknown(&'a Value), +} + /// A configuration delta. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ConfigDelta { @@ -1055,6 +1079,27 @@ impl ConversationStream { }) } + /// Returns every entry in the stream, in order, including the ones that are + /// not conversation events. + /// + /// [`Self::iter_events_by_turn`] and [`Self::iter`] both yield conversation + /// events alone, which is what building a provider request wants. + /// A reader showing the stream to somebody wants the config deltas, + /// compaction overlays and unrecognized entries too — they are part of + /// what happened. + /// + /// Borrows throughout, and allocates nothing: the point of this over + /// serializing the stream is that nothing is copied or re-encoded on the + /// way out. + pub fn iter_entries(&self) -> impl Iterator> { + self.events.iter().map(|internal| match internal { + InternalEvent::Event(event) => StreamEntry::Event(event), + InternalEvent::ConfigDelta(delta) => StreamEntry::ConfigDelta(delta), + InternalEvent::Compaction(compaction) => StreamEntry::Compaction(compaction), + InternalEvent::Unknown(value) => StreamEntry::Unknown(value), + }) + } + /// Returns the number of turns in the stream. /// /// A turn is delimited by [`TurnStart`] events. From a8ab6f7b6fc0bc7b3891a32568f48bf44a723488 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Wed, 19 Aug 2026 07:52:35 +0200 Subject: [PATCH 3/8] feat(plugin, cli): Report `pinned_at` in conversation summaries A plugin listing conversations received the title, the last activation time and the event count, but nothing about pinning. A pinned conversation was indistinguishable from any other, so a plugin could neither mark it nor sort by it without opening every conversation to find out. `ConversationSummary` carries the `pinned_at` timestamp the conversation metadata already holds. The field is skipped when serializing an unpinned conversation and defaults to absent when reading, so a plugin built against the older shape keeps working and one built against the newer shape reads an older host's messages. Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/plugin/dispatch.rs | 1 + crates/jp_plugin/src/lib_tests.rs | 1 + crates/jp_plugin/src/message.rs | 4 ++++ crates/plugins/command/serve-web/src/client_tests.rs | 1 + 4 files changed, 7 insertions(+) diff --git a/crates/jp_cli/src/cmd/plugin/dispatch.rs b/crates/jp_cli/src/cmd/plugin/dispatch.rs index 8802b88a3..963a9933e 100644 --- a/crates/jp_cli/src/cmd/plugin/dispatch.rs +++ b/crates/jp_cli/src/cmd/plugin/dispatch.rs @@ -284,6 +284,7 @@ fn handle_list_conversations(workspace: &Workspace, req_id: Option) -> H id: id.as_deciseconds().to_string(), title: meta.title.clone(), last_activated_at: meta.last_activated_at, + pinned_at: meta.pinned_at, events_count: meta.events_count, }) .collect(); diff --git a/crates/jp_plugin/src/lib_tests.rs b/crates/jp_plugin/src/lib_tests.rs index f6a53d7ec..3280cf205 100644 --- a/crates/jp_plugin/src/lib_tests.rs +++ b/crates/jp_plugin/src/lib_tests.rs @@ -10,6 +10,7 @@ fn conversations_response_serializes_without_null_id() { id: "123".to_owned(), title: Some("Test".to_owned()), last_activated_at: chrono::Utc::now(), + pinned_at: None, events_count: 5, }], }); diff --git a/crates/jp_plugin/src/message.rs b/crates/jp_plugin/src/message.rs index 8ba34bd8f..640824e62 100644 --- a/crates/jp_plugin/src/message.rs +++ b/crates/jp_plugin/src/message.rs @@ -166,6 +166,10 @@ pub struct ConversationSummary { /// When the conversation was last activated. pub last_activated_at: DateTime, + /// When the conversation was pinned, absent if it is not pinned. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pinned_at: Option>, + /// Number of events in the conversation. pub events_count: usize, } diff --git a/crates/plugins/command/serve-web/src/client_tests.rs b/crates/plugins/command/serve-web/src/client_tests.rs index 71697b0a3..ad6e17cd2 100644 --- a/crates/plugins/command/serve-web/src/client_tests.rs +++ b/crates/plugins/command/serve-web/src/client_tests.rs @@ -51,6 +51,7 @@ async fn list_conversations_roundtrip() { id: "123".to_owned(), title: Some("Test".to_owned()), last_activated_at: chrono::Utc::now(), + pinned_at: None, events_count: 5, }], }); From 1b585517d1492f984ea4cfb4650b0b7ed81deeaa Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Wed, 19 Aug 2026 08:07:43 +0200 Subject: [PATCH 4/8] feat(ffi): Read JP workspaces over a C ABI A native reader cannot call `jp_workspace` without a boundary, and the options for building one are all worse than a direct link. Shelling out to `jp --format json` binds to shapes `jp_cli::output` derives from table rows, which are not stable and would be frozen by Hyrum's Law the moment anything depended on them, and pays a full workspace load per read. Reading `.jp/` from the other language reimplements the storage format twice over and makes the on-disk layout a public contract. A sidecar process buys failure isolation that a read-only viewer does not need, at the cost of supervision and a protocol. `jp_ffi` compiles as a static library beside an rlib and exposes six entry points: open a workspace, list its conversations, read one conversation's turns, close the handle, free a returned string, and collect the last error. The rlib is what lets those entry points be unit-tested in-process rather than only through a linked app. Four rules hold the boundary together. Every entry point catches panics, because unwinding into the calling language is undefined behavior; a caught panic becomes a null return and a message. Failures return null and leave that message in a thread-local slot for `jp_last_error`. Only owned data crosses: a read copies out and drops its lock guard before returning, so no guard, reference or borrow escapes. And Rust frees what Rust allocates, which is what `jp_string_free` is for. Reads also measure their own phases and report them through an optional out-parameter. The timings ride back on the call that produced them rather than on a call of their own, so they cannot be attributed to the wrong read when two overlap, and they are durations rather than timestamps because the caller already has a clock and two that nearly agree are worse than one. Measuring happens here; writing does not. `display` decides what a reader shows. Which events carry prose, and where turn boundaries fall, are judgements about the conversation model rather than about any one reader: events before the first `TurnStart` form an implicit leading turn, and a `TurnStart` opens a new one only when the turn before it holds something. Neither is recoverable from the event shape, so both belong on this side rather than being re-derived by every reader. It reads the typed stream instead of a serialized copy, which would base64-encode the fields storage encodes only to decode them again, reparse every timestamp, and allocate a second copy of the conversation to read four fields off it. The crate depends on `jp_workspace`, `jp_conversation` and `jp_plugin`. Not `jp_config`, and not `jp_cli`: a reader has no per-tool style to apply, no reasoning display mode and no hidden-tool filtering, so keeping `jp_config` out keeps the layered load pipeline out with it. `just build-ffi` builds the library, stages it with a generated header under `apps/macos/.build/`, and is what Xcode invokes from a build phase so there is one build entry point rather than two competing ones. It asks cargo where it writes rather than assuming `./target`, because the target directory is redirectable and sibling worktrees here share one outside the checkout. Signed-off-by: Jean Mertz --- .gitignore | 11 + .ignore | 8 + Cargo.lock | 19 + crates/jp_ffi/Cargo.toml | 40 ++ crates/jp_ffi/cbindgen.toml | 12 + crates/jp_ffi/src/display.rs | 127 ++++++ crates/jp_ffi/src/display_tests.rs | 262 +++++++++++ crates/jp_ffi/src/error.rs | 60 +++ crates/jp_ffi/src/lib.rs | 343 +++++++++++++++ crates/jp_ffi/src/lib_tests.rs | 684 +++++++++++++++++++++++++++++ crates/jp_ffi/src/timing.rs | 98 +++++ crates/jp_ffi/src/timing_tests.rs | 88 ++++ justfile | 54 +++ 13 files changed, 1806 insertions(+) create mode 100644 crates/jp_ffi/Cargo.toml create mode 100644 crates/jp_ffi/cbindgen.toml create mode 100644 crates/jp_ffi/src/display.rs create mode 100644 crates/jp_ffi/src/display_tests.rs create mode 100644 crates/jp_ffi/src/error.rs create mode 100644 crates/jp_ffi/src/lib.rs create mode 100644 crates/jp_ffi/src/lib_tests.rs create mode 100644 crates/jp_ffi/src/timing.rs create mode 100644 crates/jp_ffi/src/timing_tests.rs diff --git a/.gitignore b/.gitignore index 966a2be60..aa7479a3c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,17 @@ /rustc-ice-* lcov.info +# macOS app: the Xcode project is generated from `apps/macos/project.yml` by +# `just gen-app`, and `.build/` holds the staged `jp_ffi` library and header, so +# only the manifest and the sources are tracked. +/apps/macos/*.xcodeproj +/apps/macos/.build +# SwiftPM's build directory for the `jpdrive` package, which is built by +# `just build-drive` rather than by Xcode. +/apps/macos/Tools/*/.build +/apps/macos/**/xcuserdata +.DS_Store + # Logs *.log /tmp diff --git a/.ignore b/.ignore index be3fdec5c..75843d6ab 100644 --- a/.ignore +++ b/.ignore @@ -1,6 +1,8 @@ # Whitelist: ignore everything, then un-ignore desired trees * !/* +!apps/ +!apps/** !crates/ !crates/contrib/ !crates/contrib/** @@ -45,6 +47,12 @@ docs/.vitepress/dist/ docs/.pnp.* docs/yarn.lock **/target/** +# Generated by `just build-ffi` and `just gen-app`; both are gitignored, and the +# `.xcodeproj` is regenerated from `apps/macos/project.yml` on every build. +apps/macos/.build/ +apps/macos/*.xcodeproj/ +# SwiftPM's build directory for the `jpdrive` package. +apps/macos/Tools/*/.build/ .git/ /tmp **/fixtures/ diff --git a/Cargo.lock b/Cargo.lock index c87759a0c..23b9739fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2375,6 +2375,25 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "jp_ffi" +version = "0.1.0" +dependencies = [ + "camino", + "camino-tempfile", + "chrono", + "datetime_literal", + "jp_conversation", + "jp_plugin", + "jp_storage", + "jp_workspace", + "pretty_assertions", + "serde", + "serde_json", + "serial_test", + "tracing", +] + [[package]] name = "jp_github" version = "0.1.0" diff --git a/crates/jp_ffi/Cargo.toml b/crates/jp_ffi/Cargo.toml new file mode 100644 index 000000000..c1484b8b1 --- /dev/null +++ b/crates/jp_ffi/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "jp_ffi" + +authors.workspace = true +description.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license-file.workspace = true +publish.workspace = true +readme.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +jp_conversation = { workspace = true } +jp_plugin = { workspace = true } +jp_workspace = { workspace = true } + +camino = { workspace = true } +serde = { workspace = true, features = ["derive", "std"] } +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true } + +[dev-dependencies] +camino-tempfile = { workspace = true } +chrono = { workspace = true } +datetime_literal = { workspace = true } +jp_storage = { workspace = true } +pretty_assertions = { workspace = true, features = ["std"] } +serial_test = { workspace = true } + +[lints] +workspace = true + +[lib] +# `staticlib` is what the native app links. `rlib` keeps the crate usable from +# Rust, which is what lets the entry points be unit-tested in-process. +crate-type = ["staticlib", "rlib"] +doctest = false diff --git a/crates/jp_ffi/cbindgen.toml b/crates/jp_ffi/cbindgen.toml new file mode 100644 index 000000000..2a98d17c9 --- /dev/null +++ b/crates/jp_ffi/cbindgen.toml @@ -0,0 +1,12 @@ +autogen_warning = "// Generated from the `jp_ffi` crate. Do not edit; run `just build-ffi`." +language = "C" +pragma_once = true +cpp_compat = true +documentation = true +documentation_style = "doxy" +usize_is_size_t = true + +[parse] +# Only `jp_ffi` declares C entry points. Its dependencies are ordinary Rust +# crates, and parsing them would cost build time to find nothing. +parse_deps = false diff --git a/crates/jp_ffi/src/display.rs b/crates/jp_ffi/src/display.rs new file mode 100644 index 000000000..756d538cb --- /dev/null +++ b/crates/jp_ffi/src/display.rs @@ -0,0 +1,127 @@ +//! What a reader should show for a conversation. +//! +//! Two judgements live here, and both are about the conversation model rather +//! than about any one reader. +//! The first is which events have prose to show: a `chat_request` is a message +//! from the user, a `chat_response` carrying a `message` is one from the +//! assistant, and nothing else draws. +//! The second is where the turn boundaries fall, which is not a rule a reader +//! can recover from the event shape — events before the first `TurnStart` form +//! an implicit leading turn, and a `TurnStart` opens a new turn only when the +//! one before it holds something. +//! +//! Both belong on this side of the boundary, where the model lives, rather than +//! being re-derived by every reader. +//! +//! Scoped to this crate for now. +//! The terminal renderer and the web view make the same judgement in their own +//! code, and a projection shared by all three is a larger change than the app +//! needs today. + +use jp_conversation::{ + ConversationEvent, EventKind, event::ChatResponse, rfc3339, stream::ConversationStream, +}; +use serde::Serialize; + +/// One turn, as a reader should present it. +/// +/// A turn with nothing to show is absent rather than empty, so a reader can +/// draw a boundary between every pair of turns it receives without checking +/// whether either holds anything. +#[derive(Debug, PartialEq, Eq, Serialize)] +pub(crate) struct DisplayTurn { + /// Where the turn sits in the conversation, counting from zero. + /// + /// The position among *all* turns, so the numbering skips any that had + /// nothing to show. + /// That keeps an index pointing at the same turn whatever a later build + /// decides to draw. + pub index: usize, + + /// What the turn has to show, oldest first. + pub events: Vec, +} + +/// One event, as a reader should present it. +/// +/// The `type` tag names the presentation, not the stored event kind: a caller +/// switches on it to decide how to draw, and needs no table of event kinds of +/// its own. +#[derive(Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum DisplayEvent { + /// A message the user sent. + UserMessage { + timestamp: String, + + /// Who wrote it, when a display name was configured at the time. + #[serde(skip_serializing_if = "Option::is_none")] + author: Option, + + text: String, + }, + + /// A message the assistant replied with. + AssistantMessage { timestamp: String, text: String }, +} + +/// Project a conversation onto the turns a reader shows. +/// +/// Reads the typed stream rather than a serialized copy of it. +/// Serializing to get here would base64-encode the fields storage encodes and +/// then decode them again, reparse and reformat every timestamp, and allocate a +/// whole second copy of the conversation — all to read four fields off it. +pub(crate) fn project_turns(stream: &ConversationStream) -> Vec { + let mut turns: Vec = Vec::new(); + + // `iter_events_by_turn` rather than `iter_turns`: the latter resolves and + // clones the accumulated config for every event and materializes the whole + // stream up front, and none of that is read here. + for (index, event) in stream.iter_events_by_turn() { + let Some(event) = project_event(event) else { + continue; + }; + + match turns.last_mut() { + Some(turn) if turn.index == index => turn.events.push(event), + _ => turns.push(DisplayTurn { + index, + events: vec![event], + }), + } + } + + turns +} + +/// One event, or `None` when it has no prose to show. +/// +/// The timestamp is formatted inside each arm rather than up front, because +/// most events in a long conversation are tool calls and reasoning and never +/// reach a caller. +fn project_event(event: &ConversationEvent) -> Option { + match &event.kind { + // Content is not optional on a request, so unlike the response below + // there is no empty case to fall through to. + EventKind::ChatRequest(request) => Some(DisplayEvent::UserMessage { + timestamp: rfc3339(event.timestamp), + author: request.author.clone(), + text: request.content.clone(), + }), + + // A response carrying reasoning or structured data has no message, and + // showing either is out of scope for the reader. + EventKind::ChatResponse(ChatResponse::Message { message }) => { + Some(DisplayEvent::AssistantMessage { + timestamp: rfc3339(event.timestamp), + text: message.clone(), + }) + } + + _ => None, + } +} + +#[cfg(test)] +#[path = "display_tests.rs"] +mod tests; diff --git a/crates/jp_ffi/src/display_tests.rs b/crates/jp_ffi/src/display_tests.rs new file mode 100644 index 000000000..54747efc3 --- /dev/null +++ b/crates/jp_ffi/src/display_tests.rs @@ -0,0 +1,262 @@ +use chrono::{DateTime, TimeDelta, Utc}; +use datetime_literal::datetime; +use jp_conversation::{ + ConversationEvent, + event::{ChatRequest, ChatResponse, ToolCallRequest, TurnStart}, + stream::ConversationStream, +}; +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::*; + +/// One fixed moment, so every expected timestamp below is the same string. +fn at() -> DateTime { + datetime!(2024-09-01 10:00:00 Z) +} + +fn event(kind: impl Into) -> ConversationEvent { + ConversationEvent::new(kind, at()) +} + +/// A stream holding `kinds`, all at the same moment. +/// +/// Built through `from_parts` because the stream's own mutators timestamp with +/// the wall clock, and every assertion below names the timestamp it expects. +fn stream(kinds: Vec) -> ConversationStream { + events(kinds.into_iter().map(event).collect()) +} + +/// A stream holding `events`. +fn events(events: Vec) -> ConversationStream { + // The config a stream is built on is required and irrelevant here, so it + // comes from the crate's own test stream rather than being spelled out. + let (config, _) = ConversationStream::new_test().to_parts().unwrap(); + let events = events + .into_iter() + .map(|event| serde_json::to_value(event).unwrap()) + .collect(); + + ConversationStream::from_parts(config, events).unwrap() +} + +#[test] +fn projects_a_chat_request_as_a_user_message() { + let stream = stream(vec![ + TurnStart.into(), + ChatRequest { + content: "What does this do?".to_owned(), + schema: None, + author: Some("Jean".to_owned()), + } + .into(), + ]); + + assert_eq!(project_turns(&stream), vec![DisplayTurn { + index: 0, + events: vec![DisplayEvent::UserMessage { + timestamp: "2024-09-01T10:00:00Z".to_owned(), + author: Some("Jean".to_owned()), + text: "What does this do?".to_owned(), + }], + }]); +} + +/// A request authored before a display name was configured has no author. +#[test] +fn projects_a_chat_request_without_an_author() { + let stream = stream(vec![TurnStart.into(), ChatRequest::from("hi").into()]); + + assert_eq!(project_turns(&stream), vec![DisplayTurn { + index: 0, + events: vec![DisplayEvent::UserMessage { + timestamp: "2024-09-01T10:00:00Z".to_owned(), + author: None, + text: "hi".to_owned(), + }], + }]); +} + +#[test] +fn projects_a_chat_response_as_an_assistant_message() { + let stream = stream(vec![ + TurnStart.into(), + ChatRequest::from("hi").into(), + ChatResponse::message("It reads conversations.").into(), + ]); + + assert_eq!(project_turns(&stream), vec![DisplayTurn { + index: 0, + events: vec![ + DisplayEvent::UserMessage { + timestamp: "2024-09-01T10:00:00Z".to_owned(), + author: None, + text: "hi".to_owned(), + }, + DisplayEvent::AssistantMessage { + timestamp: "2024-09-01T10:00:00Z".to_owned(), + text: "It reads conversations.".to_owned(), + }, + ], + }]); +} + +/// Every event kind that is not a message is absent from the projection — +/// reasoning and structured output included, both of which are chat responses +/// carrying no message and must not be mistaken for the assistant's reply. +#[test] +fn drops_every_event_with_no_prose_to_show() { + let stream = stream(vec![ + TurnStart.into(), + ChatRequest::from("hi").into(), + ChatResponse::reasoning("thinking").into(), + ToolCallRequest::new( + "call-1".to_owned(), + "read_file".to_owned(), + serde_json::Map::new(), + ) + .into(), + ChatResponse::structured(json!({ "answer": 42 })).into(), + ChatResponse::message("done").into(), + ]); + + assert_eq!(project_turns(&stream), vec![DisplayTurn { + index: 0, + events: vec![ + DisplayEvent::UserMessage { + timestamp: "2024-09-01T10:00:00Z".to_owned(), + author: None, + text: "hi".to_owned(), + }, + DisplayEvent::AssistantMessage { + timestamp: "2024-09-01T10:00:00Z".to_owned(), + text: "done".to_owned(), + }, + ], + }]); +} + +/// The boundary rule is the stream's own: a `TurnStart` opens a new turn only +/// when the one before it holds something, so the leading marker here does not +/// produce an empty turn 0 ahead of the first request. +#[test] +fn groups_events_into_the_turn_they_belong_to() { + let stream = stream(vec![ + TurnStart.into(), + ChatRequest::from("first question").into(), + ChatResponse::message("first answer").into(), + TurnStart.into(), + ChatRequest::from("second question").into(), + ChatResponse::message("second answer").into(), + ]); + + let turns = project_turns(&stream); + let texts: Vec<(usize, Vec<&str>)> = turns + .iter() + .map(|turn| { + let texts = turn + .events + .iter() + .map(|event| match event { + DisplayEvent::UserMessage { text, .. } + | DisplayEvent::AssistantMessage { text, .. } => text.as_str(), + }) + .collect(); + + (turn.index, texts) + }) + .collect(); + + assert_eq!(texts, vec![ + (0, vec!["first question", "first answer"]), + (1, vec!["second question", "second answer"]), + ]); +} + +/// A turn whose every event is a tool call is absent rather than empty, so a +/// reader drawing a boundary between consecutive turns never draws two against +/// nothing. +/// +/// The index of the turn after it still counts the dropped one, so an index +/// names the same turn whatever a later build decides to draw. +#[test] +fn drops_a_turn_with_nothing_to_show_and_keeps_the_numbering() { + let stream = stream(vec![ + TurnStart.into(), + ChatRequest::from("visible").into(), + TurnStart.into(), + ToolCallRequest::new( + "call-1".to_owned(), + "read_file".to_owned(), + serde_json::Map::new(), + ) + .into(), + TurnStart.into(), + ChatRequest::from("also visible").into(), + ]); + + let indices: Vec = project_turns(&stream) + .iter() + .map(|turn| turn.index) + .collect(); + + assert_eq!(indices, vec![0, 2]); +} + +/// Events written before any `TurnStart` are a turn of their own, which is the +/// stream's implicit leading turn rather than something invented here. +#[test] +fn projects_events_before_the_first_turn_start_as_the_leading_turn() { + let stream = stream(vec![ + ChatRequest::from("no marker ahead of me").into(), + TurnStart.into(), + ChatRequest::from("after the marker").into(), + ]); + + let indices: Vec = project_turns(&stream) + .iter() + .map(|turn| turn.index) + .collect(); + + assert_eq!(indices, vec![0, 1]); +} + +#[test] +fn projects_an_empty_stream_as_no_turns() { + assert_eq!(project_turns(&events(vec![])), vec![]); +} + +/// Sub-second precision survives, because a reader ordering events needs it and +/// two events in one millisecond is ordinary. +#[test] +fn keeps_sub_second_precision_in_a_timestamp() { + let stream = events(vec![ConversationEvent::new( + ChatRequest::from("hi"), + at() + TimeDelta::microseconds(418_293), + )]); + + assert_eq!(project_turns(&stream), vec![DisplayTurn { + index: 0, + events: vec![DisplayEvent::UserMessage { + timestamp: "2024-09-01T10:00:00.418293Z".to_owned(), + author: None, + text: "hi".to_owned(), + }], + }]); +} + +/// The wire shape a reader decodes: turns carrying an index and their events, +/// each tagged with its presentation. +#[test] +fn serializes_turns_carrying_events_tagged_by_presentation() { + let stream = stream(vec![ + TurnStart.into(), + ChatRequest::from("hi").into(), + ChatResponse::message("hello").into(), + ]); + + assert_eq!( + serde_json::to_string(&project_turns(&stream)).unwrap(), + r#"[{"index":0,"events":[{"type":"user_message","timestamp":"2024-09-01T10:00:00Z","text":"hi"},{"type":"assistant_message","timestamp":"2024-09-01T10:00:00Z","text":"hello"}]}]"# + ); +} diff --git a/crates/jp_ffi/src/error.rs b/crates/jp_ffi/src/error.rs new file mode 100644 index 000000000..77a18ee89 --- /dev/null +++ b/crates/jp_ffi/src/error.rs @@ -0,0 +1,60 @@ +//! The thread-local failure slot that backs `jp_last_error`. + +use std::{ + cell::RefCell, + ffi::CString, + panic::{self, AssertUnwindSafe}, +}; + +use tracing::warn; + +thread_local! { + /// The most recent failure on this thread, until `jp_last_error` takes it. + static LAST_ERROR: RefCell> = const { RefCell::new(None) }; +} + +/// Run `body`, returning `None` when it fails or panics. +/// +/// The failure message is left in the thread-local slot for `jp_last_error` to +/// collect. +/// `label` names the entry point, because a caught panic carries no location of +/// its own by the time it reaches here. +pub(crate) fn guard(label: &str, body: impl FnOnce() -> Result) -> Option { + match panic::catch_unwind(AssertUnwindSafe(body)) { + Ok(Ok(value)) => Some(value), + Ok(Err(message)) => { + set(message); + None + } + Err(_) => { + set(format!("{label} panicked")); + None + } + } +} + +/// Take the pending failure message, leaving the slot empty. +pub(crate) fn take() -> Option { + LAST_ERROR + .try_with(|slot| slot.borrow_mut().take()) + .ok() + .flatten() +} + +/// Replace the pending failure message. +fn set(message: String) { + warn!(message, "FFI call failed."); + + let message = CString::new(message).unwrap_or_else(|error| { + // An interior NUL cannot cross a C string boundary. Truncating there + // keeps the leading, most specific part of the message rather than + // dropping the failure entirely. + let bytes = error.into_vec(); + let end = bytes.iter().position(|byte| *byte == 0).unwrap_or_default(); + CString::new(&bytes[..end]).expect("no NUL before the first NUL") + }); + + // `try_with` fails only after this thread's destructors have run, at which + // point no caller is left to read the message. + let _err = LAST_ERROR.try_with(|slot| slot.replace(Some(message))); +} diff --git a/crates/jp_ffi/src/lib.rs b/crates/jp_ffi/src/lib.rs new file mode 100644 index 000000000..e2dede4f8 --- /dev/null +++ b/crates/jp_ffi/src/lib.rs @@ -0,0 +1,343 @@ +//! A C ABI over [`jp_workspace`], for reading JP conversations from a native +//! app. +//! +//! [`jp_workspace_open`] hands back an opaque handle that the caller owns until +//! it passes the handle to [`jp_workspace_close`]. +//! Reads copy their result into a freshly allocated, NUL-terminated JSON string +//! which the caller releases with [`jp_string_free`]; no lock guard, reference, +//! or borrow of workspace state crosses the boundary. +//! +//! A read also measures the phases of its own work, and reports them through an +//! optional out-parameter the caller may pass as null. +//! They ride back on the call that produced them rather than on a call of their +//! own, so timings and the work they describe cannot drift apart when two reads +//! overlap. +//! +//! Every entry point catches panics rather than letting one unwind into the +//! calling language, which would be undefined behavior. +//! A failing call returns null and leaves a message for [`jp_last_error`]. + +mod display; +mod error; +mod timing; + +use std::{ + ffi::{CStr, CString, c_char}, + ptr, +}; + +use camino::Utf8Path; +use jp_conversation::ConversationId; +use jp_plugin::message::ConversationSummary; +use jp_workspace::Workspace; + +use crate::{display::project_turns, error::guard, timing::Timings}; + +/// An open workspace, owned by the caller between [`jp_workspace_open`] and +/// [`jp_workspace_close`]. +pub struct WorkspaceRef { + workspace: Workspace, +} + +/// Open the workspace containing `path` and load its conversation index. +/// +/// `path` may be the workspace root or any directory inside it. +/// Returns null on failure, leaving a message for [`jp_last_error`]. +/// +/// Opening writes to disk: the user-local conversation store is created if +/// missing and the workspace ID is persisted, as `jp` does. +/// +/// Corrupt conversations are **not** moved aside. +/// Sanitizing a store is a deliberate act that trashes data, and a reader has +/// no business doing it as a side effect of looking; a conversation whose +/// metadata will not load is simply left out of the list. +/// +/// # Safety +/// +/// `path` must point to a NUL-terminated string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn jp_workspace_open(path: *const c_char) -> *mut WorkspaceRef { + guard("jp_workspace_open", || { + // SAFETY: `path` is NUL-terminated per this function's contract. The + // borrow does not escape: it is consumed by `Workspace::open` below, + // which copies the path, well before this call returns to the caller + // that owns the string. + let path = unsafe { borrow_str(path, "path") }?; + let mut workspace = Workspace::open(Utf8Path::new(path)).map_err(|e| e.to_string())?; + + workspace.load_conversation_index(); + + Ok(WorkspaceRef { workspace }) + }) + .map_or(ptr::null_mut(), |opened| Box::into_raw(Box::new(opened))) +} + +/// Return the workspace's conversations as a JSON array, most recently active +/// first. +/// +/// Each element carries `id`, `title`, `last_activated_at` and `events_count`, +/// plus `pinned_at` for a pinned conversation. +/// Timestamps are RFC 3339, with a fractional-seconds part when the stored +/// value has one. +/// Returns null on failure, leaving a message for [`jp_last_error`]. +/// Release the result with [`jp_string_free`]. +/// +/// `timings` may be null. +/// Given a slot, the call writes a JSON array of `{"name", "duration_ms"}` +/// objects naming what it spent its time on — `index.read`, `sort`, +/// `serialize` — which the caller also releases with [`jp_string_free`]. +/// +/// # Safety +/// +/// `ws` must be a handle from [`jp_workspace_open`] that has not been closed, +/// and `timings` must be null or point to a writable `*mut c_char`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn jp_workspace_conversations( + ws: *mut WorkspaceRef, + timings: *mut *mut c_char, +) -> *mut c_char { + let mut measured = Timings::default(); + + let json = guard("jp_workspace_conversations", || { + // SAFETY: `ws` is a live handle from `jp_workspace_open` per this + // function's contract, so it points to a `WorkspaceRef` that outlives + // the borrow. The borrow ends before this call returns, and the shared + // reference is compatible with the caller's ownership of the handle: + // nothing here mutates through it. + let opened = unsafe { borrow_workspace(ws) }?; + + // Collecting first releases every read guard before the JSON is built, + // so nothing borrowed from the workspace outlives this call. + let mut summaries: Vec<_> = measured.measure("index.read", || { + opened + .workspace + .conversations() + .map(|(id, metadata)| ConversationSummary { + id: id.as_deciseconds().to_string(), + title: metadata.title.clone(), + last_activated_at: metadata.last_activated_at, + pinned_at: metadata.pinned_at, + events_count: metadata.events_count, + }) + .collect() + }); + + // Most recently active first, which is the order a reader wants and the + // one `jp conversation ls` shows. Ordering here rather than in each + // caller keeps them from disagreeing, and keeps the subtlety in one + // place: these are timestamps, and comparing them as text would put + // `12:30:00.5Z` before `12:30:00Z` because `.` precedes `Z`. + // + // The ID breaks ties. It is a timestamp too, so it keeps equal-activity + // conversations newest-first among themselves. + measured.measure("sort", || { + summaries.sort_by(|a, b| { + b.last_activated_at + .cmp(&a.last_activated_at) + .then_with(|| b.id.cmp(&a.id)) + }); + }); + + let json = measured.measure("serialize", || { + serde_json::to_string(&summaries).map_err(|e| e.to_string()) + })?; + + CString::new(json).map_err(|e| format!("conversation list is not a C string: {e}")) + }); + + // SAFETY: `timings` is null or writable per this function's contract. + unsafe { timing::publish(timings, &measured) }; + + json.map_or(ptr::null_mut(), CString::into_raw) +} + +/// Return a conversation's turns as a JSON array, oldest first. +/// +/// `conversation_id` is the decimal decisecond timestamp that identifies the +/// conversation, as reported by [`jp_workspace_conversations`]. +/// Each element carries an `index` naming where the turn sits in the +/// conversation, and an `events` array of what it has to show. +/// Each event carries a `timestamp` in RFC 3339 and a `type` tag naming how to +/// present it: `user_message` and `assistant_message`, both carrying `text`, +/// the first with an `author` where one is known. +/// +/// Only those two presentations exist. +/// Tool calls, reasoning, inquiries, config changes and turn markers have no +/// prose to show and are absent, as is any turn left with nothing — so a +/// caller can draw a boundary between consecutive turns without checking +/// whether either holds anything. +/// +/// The tag names the presentation rather than the stored event kind, so a +/// caller decides how to draw without keeping its own table of event kinds — a +/// table it would have to keep in step with this crate by hand. +/// Returns null on failure, leaving a message for [`jp_last_error`]. +/// Release the result with [`jp_string_free`]. +/// +/// `timings` may be null. +/// Given a slot, the call writes a JSON array of `{"name", "duration_ms"}` +/// objects naming what it spent its time on — `storage.read`, `project`, +/// `serialize` — which the caller also releases with [`jp_string_free`]. +/// +/// # Safety +/// +/// `ws` must be a handle from [`jp_workspace_open`] that has not been closed, +/// `conversation_id` must point to a NUL-terminated string, and `timings` must +/// be null or point to a writable `*mut c_char`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn jp_workspace_events( + ws: *mut WorkspaceRef, + conversation_id: *const c_char, + timings: *mut *mut c_char, +) -> *mut c_char { + let mut measured = Timings::default(); + + let json = guard("jp_workspace_events", || { + // SAFETY: `ws` is a live handle from `jp_workspace_open` and + // `conversation_id` is NUL-terminated, both per this function's + // contract. Neither borrow outlives the call. + let (opened, id) = unsafe { + ( + borrow_workspace(ws)?, + borrow_str(conversation_id, "conversation_id")?, + ) + }; + + let id = ConversationId::try_from_deciseconds_str(id) + .map_err(|e| format!("invalid conversation ID: {e}"))?; + let handle = opened + .workspace + .acquire_conversation(&id) + .map_err(|e| format!("conversation not found: {e}"))?; + + // Scoped so the read guard is released before the JSON leaves the + // boundary: no borrow of workspace state may outlive this call. + let json = { + let events = measured.measure("storage.read", || { + opened + .workspace + .events(&handle) + .map_err(|e| format!("failed to load events: {e}")) + })?; + + let display = measured.measure("project", || project_turns(&events)); + + measured.measure("serialize", || { + serde_json::to_string(&display).map_err(|e| e.to_string()) + })? + }; + + CString::new(json).map_err(|e| format!("event list is not a C string: {e}")) + }); + + // SAFETY: `timings` is null or writable per this function's contract. + unsafe { timing::publish(timings, &measured) }; + + json.map_or(ptr::null_mut(), CString::into_raw) +} + +/// Release a workspace handle from [`jp_workspace_open`]. +/// +/// Does nothing when `ws` is null. +/// +/// # Safety +/// +/// `ws` must be a handle from [`jp_workspace_open`], and must not be used again +/// afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn jp_workspace_close(ws: *mut WorkspaceRef) { + if ws.is_null() { + return; + } + + let _closed = guard("jp_workspace_close", || { + // SAFETY: `ws` is non-null (checked above) and came from + // `Box::into_raw` in `jp_workspace_open`, so reclaiming it as a `Box` + // pairs the allocation with its original allocator. The caller + // promises not to use the handle again, so no other alias exists. + drop(unsafe { Box::from_raw(ws) }); + Ok(()) + }); +} + +/// Release a string returned by this library. +/// +/// Does nothing when `string` is null. +/// +/// # Safety +/// +/// `string` must be a pointer returned by this library, and must not be used +/// again afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn jp_string_free(string: *mut c_char) { + if string.is_null() { + return; + } + + let _freed = guard("jp_string_free", || { + // SAFETY: `string` is non-null (checked above) and came from + // `CString::into_raw` in this library, so reclaiming it as a `CString` + // pairs the allocation with its original allocator. The caller + // promises not to use the pointer again, so no other alias exists. + drop(unsafe { CString::from_raw(string) }); + Ok(()) + }); +} + +/// Take the calling thread's most recent failure message. +/// +/// Returns null when no call has failed since the last time the message was +/// taken. +/// Release a non-null result with [`jp_string_free`]. +#[unsafe(no_mangle)] +pub extern "C" fn jp_last_error() -> *mut c_char { + // Deliberately not routed through `guard`: recording a failure writes to + // the same slot this reads, and a failure to report a failure has nowhere + // left to go. + std::panic::catch_unwind(error::take) + .ok() + .flatten() + .map_or(ptr::null_mut(), CString::into_raw) +} + +/// Borrow a C string argument. +/// +/// `name` labels the argument in the returned message. +/// +/// # Safety +/// +/// `ptr` must be null, or point to a NUL-terminated string that outlives the +/// returned reference. +unsafe fn borrow_str<'a>(ptr: *const c_char, name: &str) -> Result<&'a str, String> { + if ptr.is_null() { + return Err(format!("{name} is null")); + } + + // SAFETY: `ptr` is non-null (checked above) and NUL-terminated per this + // function's contract, so the string has a bounded extent. The caller also + // guarantees it stays valid and unmodified for the returned lifetime, which + // is what makes the unbounded `'a` sound at every call site. + unsafe { CStr::from_ptr(ptr) } + .to_str() + .map_err(|e| format!("{name} is not valid UTF-8: {e}")) +} + +/// Borrow a workspace handle. +/// +/// # Safety +/// +/// `ptr` must be null, or a handle from [`jp_workspace_open`] that has not been +/// closed and outlives the returned reference. +unsafe fn borrow_workspace<'a>(ptr: *mut WorkspaceRef) -> Result<&'a WorkspaceRef, String> { + if ptr.is_null() { + return Err("workspace handle is null".to_owned()); + } + + // SAFETY: `ptr` is non-null (checked above) and, per this function's + // contract, an unclosed handle from `jp_workspace_open` — hence properly + // aligned, initialized, and valid for the returned lifetime. + Ok(unsafe { &*ptr }) +} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/crates/jp_ffi/src/lib_tests.rs b/crates/jp_ffi/src/lib_tests.rs new file mode 100644 index 000000000..b3976b7d6 --- /dev/null +++ b/crates/jp_ffi/src/lib_tests.rs @@ -0,0 +1,684 @@ +use std::env; + +use camino::Utf8PathBuf; +use camino_tempfile::{Utf8TempDir, tempdir}; +use chrono::Duration; +use datetime_literal::datetime; +use jp_conversation::{Conversation, ConversationId}; +use jp_storage::backend::FsStorageBackend; +use serial_test::serial; + +use super::*; + +/// Snapshot the env vars workspace opening depends on, so each test can point +/// user-local storage at a temp directory and put the process back as it was. +struct EnvGuard { + jp: Option, + xdg: Option, +} + +impl EnvGuard { + fn redirect(user_data: &Utf8PathBuf) -> Self { + let guard = Self { + jp: env::var("JP_USER_DATA_DIR").ok(), + xdg: env::var("XDG_DATA_HOME").ok(), + }; + + // SAFETY: mutating the environment races with any concurrent reader in + // the process. Every test that constructs an `EnvGuard` is marked + // `#[serial(env_vars)]`, so no other test touches these variables + // concurrently, and nothing under test reads them from another thread. + unsafe { + env::set_var("JP_USER_DATA_DIR", user_data.as_str()); + env::remove_var("XDG_DATA_HOME"); + } + + guard + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: as in `redirect` — the `#[serial(env_vars)]` tests that own + // an `EnvGuard` are the only writers, and the guard drops while that + // serialized test still holds the lock. + unsafe { + match &self.jp { + Some(value) => env::set_var("JP_USER_DATA_DIR", value), + None => env::remove_var("JP_USER_DATA_DIR"), + } + match &self.xdg { + Some(value) => env::set_var("XDG_DATA_HOME", value), + None => env::remove_var("XDG_DATA_HOME"), + } + } + } +} + +/// A workspace on disk holding one conversation with a fixed ID and title. +fn workspace_with_one_conversation() -> (Utf8TempDir, EnvGuard, Utf8PathBuf) { + workspace_holding(&Conversation { + title: Some("Reading list".to_owned()), + last_activated_at: datetime!(2024-09-02 12:30:00 Z), + ..Conversation::default() + }) +} + +/// A workspace on disk holding `conversation` under a fixed ID. +fn workspace_holding(conversation: &Conversation) -> (Utf8TempDir, EnvGuard, Utf8PathBuf) { + let tmp = tempdir().unwrap(); + let guard = EnvGuard::redirect(&tmp.path().join("user-data")); + + let root = tmp.path().join("my-workspace"); + let fs = FsStorageBackend::new(&root.join(".jp")).unwrap(); + fs.write_test_conversation( + &ConversationId::try_from(datetime!(2024-09-01 00:00:00 Z)).unwrap(), + conversation, + ); + + (tmp, guard, root) +} + +/// The conversation ID every fixture uses, as the FFI reports it. +const CONVERSATION_ID: &str = "17251488000"; + +/// Write an events file for the fixture conversation, replacing the empty one. +/// +/// The JSON is written verbatim so the test pins the on-disk shape the loader +/// accepts, rather than whatever the stream builder happens to emit today. +fn write_events(root: &Utf8PathBuf, events_json: &str) { + let fs = FsStorageBackend::new(&root.join(".jp")).unwrap(); + let id = ConversationId::try_from(datetime!(2024-09-01 00:00:00 Z)).unwrap(); + let path = fs + .conversation_events_path(&id) + .expect("conversation exists"); + std::fs::write(path, events_json).unwrap(); +} + +/// Open the workspace at `root` and return the conversation's event JSON. +fn events_json(root: &Utf8PathBuf, conversation_id: &str) -> String { + let path = CString::new(root.as_str()).unwrap(); + let id = CString::new(conversation_id).unwrap(); + + // SAFETY: both `CString`s outlive the calls that borrow them. `ws` is + // checked non-null, used only between open and close, and not touched + // afterwards. + unsafe { + let ws = jp_workspace_open(path.as_ptr()); + assert!(!ws.is_null(), "open failed: {:?}", take_last_error()); + + let json = take_string(jp_workspace_events(ws, id.as_ptr(), ptr::null_mut())); + jp_workspace_close(ws); + json + } +} + +/// Open the workspace at `root`, read the conversation's events, and return the +/// timings the call reported. +fn events_timings(root: &Utf8PathBuf, conversation_id: &str) -> String { + let path = CString::new(root.as_str()).unwrap(); + let id = CString::new(conversation_id).unwrap(); + let mut timings: *mut c_char = ptr::null_mut(); + + // SAFETY: both `CString`s outlive the calls that borrow them, and `timings` + // is a live, writable slot. `ws` is checked non-null, used only between + // open and close, and not touched afterwards. + unsafe { + let ws = jp_workspace_open(path.as_ptr()); + assert!(!ws.is_null(), "open failed: {:?}", take_last_error()); + + jp_string_free(jp_workspace_events(ws, id.as_ptr(), &raw mut timings)); + jp_workspace_close(ws); + } + + take_string(timings) +} + +/// Open the workspace at `root`, read its conversations, and return the timings +/// the call reported. +fn conversations_timings(root: &Utf8PathBuf) -> String { + let path = CString::new(root.as_str()).unwrap(); + let mut timings: *mut c_char = ptr::null_mut(); + + // SAFETY: `path` outlives the call that borrows it, and `timings` is a + // live, writable slot. `ws` is checked non-null, used only between open and + // close, and not touched afterwards. + unsafe { + let ws = jp_workspace_open(path.as_ptr()); + assert!(!ws.is_null(), "open failed: {:?}", take_last_error()); + + jp_string_free(jp_workspace_conversations(ws, &raw mut timings)); + jp_workspace_close(ws); + } + + take_string(timings) +} + +/// The span names in a timings payload, in the order they were measured. +fn timing_names(json: &str) -> Vec { + serde_json::from_str::>(json) + .expect("timings are a JSON array") + .iter() + .map(|span| span["name"].as_str().expect("a span has a name").to_owned()) + .collect() +} + +/// Open the workspace at `root` and return its conversation JSON. +fn conversations_json(root: &Utf8PathBuf) -> String { + let path = CString::new(root.as_str()).unwrap(); + + // SAFETY: `path` is a live `CString`, so the pointer is NUL-terminated and + // valid for the call. `ws` is checked non-null before being passed on, is + // used only between open and close, and is not touched after closing. + unsafe { + let ws = jp_workspace_open(path.as_ptr()); + assert!(!ws.is_null(), "open failed: {:?}", take_last_error()); + + let json = take_string(jp_workspace_conversations(ws, ptr::null_mut())); + jp_workspace_close(ws); + json + } +} + +/// Take the pending error as an owned string, freeing the C allocation. +fn take_last_error() -> Option { + let raw = jp_last_error(); + if raw.is_null() { + return None; + } + + // SAFETY: `raw` is non-null (checked above) and came from `jp_last_error`, + // so it is a NUL-terminated string this library allocated. It is read + // before being freed, and the pointer is not used afterwards. + let message = unsafe { + let message = CStr::from_ptr(raw).to_str().unwrap().to_owned(); + jp_string_free(raw); + message + }; + + Some(message) +} + +/// Read a returned string as owned, freeing the C allocation. +/// +/// A null return means the call failed and left a message behind, so the +/// message is what the failure reports — without it the panic says only that +/// something went wrong. +fn take_string(raw: *mut c_char) -> String { + assert!( + !raw.is_null(), + "expected a string, got null: {:?}", + take_last_error() + ); + + // SAFETY: `raw` is non-null (checked above) and came from a library call + // that returns an owned, NUL-terminated string. It is read before being + // freed, and the pointer is not used afterwards. + unsafe { + let value = CStr::from_ptr(raw).to_str().unwrap().to_owned(); + jp_string_free(raw); + value + } +} + +#[test] +#[serial(env_vars)] +fn conversations_returns_the_index_as_json() { + let (_tmp, _guard, root) = workspace_with_one_conversation(); + + assert_eq!( + conversations_json(&root), + r#"[{"id":"17251488000","title":"Reading list","last_activated_at":"2024-09-02T12:30:00Z","events_count":0}]"# + ); +} + +/// Timestamps keep whatever sub-second precision the conversation was stored +/// with, so the emitted RFC 3339 string has a fractional part for any +/// conversation JP created from a wall clock. +/// +/// Pinned because a decoder written against whole-second output alone (Swift's +/// `.iso8601` strategy, for one) parses the test above and then fails on every +/// real workspace. +#[test] +#[serial(env_vars)] +fn conversations_keeps_sub_second_timestamp_precision() { + let (_tmp, _guard, root) = workspace_holding(&Conversation { + title: Some("Reading list".to_owned()), + last_activated_at: datetime!(2024-09-02 12:30:00 Z) + Duration::microseconds(123_456), + ..Conversation::default() + }); + + assert_eq!( + conversations_json(&root), + r#"[{"id":"17251488000","title":"Reading list","last_activated_at":"2024-09-02T12:30:00.123456Z","events_count":0}]"# + ); +} + +/// A pinned conversation reports when it was pinned, so a reader can group +/// pinned conversations without asking a second time. +/// +/// The key is absent for an unpinned conversation, which is what keeps the +/// payload in `conversations_returns_the_index_as_json` unchanged. +#[test] +#[serial(env_vars)] +fn conversations_report_when_a_conversation_was_pinned() { + let (_tmp, _guard, root) = workspace_holding(&Conversation { + title: Some("Reading list".to_owned()), + last_activated_at: datetime!(2024-09-02 12:30:00 Z), + pinned_at: Some(datetime!(2024-09-03 08:00:00 Z)), + ..Conversation::default() + }); + + assert_eq!( + conversations_json(&root), + r#"[{"id":"17251488000","title":"Reading list","last_activated_at":"2024-09-02T12:30:00Z","pinned_at":"2024-09-03T08:00:00Z","events_count":0}]"# + ); +} + +/// Opening any directory inside the workspace opens the workspace, so the app +/// can hand over whatever directory the user picked. +#[test] +#[serial(env_vars)] +fn open_accepts_a_directory_inside_the_workspace() { + let (_tmp, _guard, root) = workspace_with_one_conversation(); + let nested = root.join("src/nested"); + std::fs::create_dir_all(&nested).unwrap(); + + assert!(conversations_json(&nested).contains(r#""title":"Reading list""#)); +} + +#[test] +#[serial(env_vars)] +fn open_reports_a_directory_that_is_not_a_workspace() { + let tmp = tempdir().unwrap(); + let _guard = EnvGuard::redirect(&tmp.path().join("user-data")); + // The entry point hardcodes `.jp`, so this assumes no workspace exists above + // the temp directory. A machine where one does makes the open succeed and + // this test fail loudly, rather than pass for the wrong reason. + let missing = tmp.path().join("no-such-directory"); + + let path = CString::new(missing.as_str()).unwrap(); + + // SAFETY: `path` is a live `CString`, so the pointer is NUL-terminated and + // valid for the call. + let ws = unsafe { jp_workspace_open(path.as_ptr()) }; + + assert!(ws.is_null()); + assert_eq!( + take_last_error(), + Some(format!("No workspace found at or above: {missing}")) + ); +} + +/// Most recently active first, so a caller renders the list as given. +/// +/// The middle conversation is half a second later than the oldest but shares +/// its whole second: ordering these as text would put it first, because `.` +/// sorts before `Z`. +#[test] +#[serial(env_vars)] +fn conversations_are_ordered_by_activity() { + let tmp = tempdir().unwrap(); + let user_data = tmp.path().join("user-data"); + let _guard = EnvGuard::redirect(&user_data); + + let root = tmp.path().join("my-workspace"); + let fs = FsStorageBackend::new(&root.join(".jp")).unwrap(); + + let activated = datetime!(2024-09-02 12:30:00 Z); + for (day, last_activated_at) in [ + (1, activated), + (2, activated + Duration::milliseconds(500)), + (3, activated + Duration::hours(1)), + ] { + let id = ConversationId::try_from(datetime!(2024-09-01 00:00:00 Z)) + .unwrap() + .as_deciseconds() + + day; + fs.write_test_conversation( + &ConversationId::try_from_deciseconds(id).unwrap(), + &Conversation { + title: Some(format!("conversation {day}")), + last_activated_at, + ..Conversation::default() + }, + ); + } + + let json = conversations_json(&root); + let titles: Vec<&str> = json + .match_indices("\"title\":\"") + .map(|(i, m)| { + let rest = &json[i + m.len()..]; + &rest[..rest.find('"').unwrap()] + }) + .collect(); + + assert_eq!(titles, [ + "conversation 3", + "conversation 2", + "conversation 1" + ]); +} + +/// The projection the app renders: turns carrying the events that have prose to +/// show, each tagged with its presentation rather than its stored event kind. +/// +/// Pinned exactly, because the Swift mirror is hand-maintained and nothing else +/// links the two definitions. +#[test] +#[serial(env_vars)] +fn events_are_projected_as_turns_of_tagged_json() { + let (_tmp, _guard, root) = workspace_with_one_conversation(); + write_events( + &root, + r#"[ + {"timestamp":"2024-09-01 10:00:00.0","type":"turn_start"}, + {"timestamp":"2024-09-01 10:00:01.0","type":"chat_request","content":"What does this do?","author":"Jean"}, + {"timestamp":"2024-09-01 10:00:02.0","type":"chat_response","reasoning":"thinking"}, + {"timestamp":"2024-09-01 10:00:03.0","type":"chat_response","message":"It reads conversations."} + ]"#, + ); + + assert_eq!( + events_json(&root, CONVERSATION_ID), + r#"[{"index":0,"events":[{"type":"user_message","timestamp":"2024-09-01T10:00:01Z","author":"Jean","text":"What does this do?"},{"type":"assistant_message","timestamp":"2024-09-01T10:00:03Z","text":"It reads conversations."}]}]"# + ); +} + +/// A stream holds more than the two kinds the reader draws, and none of the +/// rest crosses the boundary — config deltas and entries written by a build +/// this one has never heard of included. +/// +/// The reader shows messages, so anything without prose is weight on the wire +/// that nothing draws. +#[test] +#[serial(env_vars)] +fn events_leave_out_the_entries_that_are_not_messages() { + let (_tmp, _guard, root) = workspace_with_one_conversation(); + write_events( + &root, + r#"[ + {"timestamp":"2024-09-01 10:00:00.0","type":"config_delta","delta":{}}, + {"timestamp":"2024-09-01 10:00:01.0","type":"chat_request","content":"hi"}, + {"timestamp":"2024-09-01 10:00:02.0","type":"some_future_event"} + ]"#, + ); + + assert_eq!( + events_json(&root, CONVERSATION_ID), + r#"[{"index":0,"events":[{"type":"user_message","timestamp":"2024-09-01T10:00:01Z","text":"hi"}]}]"# + ); +} + +/// Events and conversation summaries report timestamps in one format, so a +/// caller needs one decoder rather than one per payload shape. +/// Storage keeps its own format; the translation happens at the boundary. +#[test] +#[serial(env_vars)] +fn event_timestamps_are_rfc3339_with_sub_second_precision_kept() { + let (_tmp, _guard, root) = workspace_with_one_conversation(); + write_events( + &root, + r#"[{"timestamp":"2024-09-01 10:00:00.123456","type":"chat_request","content":"hi"}]"#, + ); + + assert_eq!( + events_json(&root, CONVERSATION_ID), + r#"[{"index":0,"events":[{"type":"user_message","timestamp":"2024-09-01T10:00:00.123456Z","text":"hi"}]}]"# + ); +} + +/// A timestamp already stored as RFC 3339 passes through unchanged, rather than +/// being mangled by a second conversion. +#[test] +#[serial(env_vars)] +fn event_timestamps_already_rfc3339_are_left_alone() { + let (_tmp, _guard, root) = workspace_with_one_conversation(); + write_events( + &root, + r#"[{"timestamp":"2024-09-01T10:00:00Z","type":"chat_request","content":"hi"}]"#, + ); + + assert_eq!( + events_json(&root, CONVERSATION_ID), + r#"[{"index":0,"events":[{"type":"user_message","timestamp":"2024-09-01T10:00:00Z","text":"hi"}]}]"# + ); +} + +#[test] +#[serial(env_vars)] +fn events_of_an_empty_conversation_are_an_empty_array() { + let (_tmp, _guard, root) = workspace_with_one_conversation(); + + assert_eq!(events_json(&root, CONVERSATION_ID), "[]"); +} + +#[test] +#[serial(env_vars)] +fn events_reports_an_unparsable_conversation_id() { + let (_tmp, _guard, root) = workspace_with_one_conversation(); + + let path = CString::new(root.as_str()).unwrap(); + let id = CString::new("not-an-id").unwrap(); + + // SAFETY: both `CString`s outlive the calls that borrow them, and `ws` is + // used only between open and close. + let json = unsafe { + let ws = jp_workspace_open(path.as_ptr()); + assert!(!ws.is_null(), "open failed: {:?}", take_last_error()); + + let json = jp_workspace_events(ws, id.as_ptr(), ptr::null_mut()); + jp_workspace_close(ws); + json + }; + + assert!(json.is_null()); + assert!( + take_last_error().is_some_and(|e| e.starts_with("invalid conversation ID:")), + "expected the ID parse failure to be reported" + ); +} + +#[test] +#[serial(env_vars)] +fn events_reports_a_conversation_that_is_not_in_the_workspace() { + let (_tmp, _guard, root) = workspace_with_one_conversation(); + + let path = CString::new(root.as_str()).unwrap(); + let id = CString::new("17251488999").unwrap(); + + // SAFETY: both `CString`s outlive the calls that borrow them, and `ws` is + // used only between open and close. + let json = unsafe { + let ws = jp_workspace_open(path.as_ptr()); + assert!(!ws.is_null(), "open failed: {:?}", take_last_error()); + + let json = jp_workspace_events(ws, id.as_ptr(), ptr::null_mut()); + jp_workspace_close(ws); + json + }; + + assert!(json.is_null()); + assert!( + take_last_error().is_some_and(|e| e.starts_with("conversation not found:")), + "expected the missing conversation to be reported" + ); +} + +/// A read attributes its own time, so a caller can tell reaching the stream +/// from projecting it from encoding the answer, rather than being told only +/// that "the library" was slow. +/// +/// `project` and not `deserialize`: the events are already typed by the time +/// this call reaches them, and nothing here parses storage. +#[test] +#[serial(env_vars)] +fn events_reports_what_the_work_cost() { + let (_tmp, _guard, root) = workspace_with_one_conversation(); + write_events( + &root, + r#"[{"timestamp":"2024-09-01 10:00:00.0","type":"chat_request","content":"hi"}]"#, + ); + + assert_eq!(timing_names(&events_timings(&root, CONVERSATION_ID)), [ + "storage.read", + "project", + "serialize" + ]); +} + +#[test] +#[serial(env_vars)] +fn conversations_reports_what_the_work_cost() { + let (_tmp, _guard, root) = workspace_with_one_conversation(); + + assert_eq!(timing_names(&conversations_timings(&root)), [ + "index.read", + "sort", + "serialize" + ]); +} + +/// A slot the caller passed is written whatever happens, so it never reads back +/// whatever it declared the variable with. +/// A call that failed before doing any of the work it measures reports an empty +/// array. +#[test] +#[serial(env_vars)] +fn a_failed_read_still_writes_the_timings_slot() { + let (_tmp, _guard, root) = workspace_with_one_conversation(); + + assert_eq!(events_timings(&root, "17251488999"), "[]"); + assert!( + take_last_error().is_some_and(|e| e.starts_with("conversation not found:")), + "expected the missing conversation to be reported" + ); +} + +/// The smallest `base_config.json` a conversation can be stored with. +/// +/// A stream is only readable once its base config finalizes into a whole +/// `AppConfig`, so a conversation with an empty one fails to load and the app +/// shows "Could Not Read Conversation" instead of a transcript. +/// These two settings are the ones with no default to fall back on. +/// +/// Copied verbatim in `apps/macos/UITests/WorkspaceFixture.swift`. +/// When a new setting becomes required, this constant and that one both need +/// it, and [`the_ui_test_fixture_layout_is_readable`] is what says so — in +/// seconds, with the missing field named, rather than as a UI test timing out +/// against a blank pane. +const UI_TEST_BASE_CONFIG: &str = r#"{"assistant":{"model":{"id":{"provider":"anthropic","name":"test"}}},"conversation":{"tools":{"*":{"run":"ask"}}}}"#; + +/// The workspace the macOS UI tests build, read back through this boundary. +/// +/// Those tests run outside the app's process and cannot call this library, so +/// they write the three storage files by hand +/// (`apps/macos/UITests/WorkspaceFixture.swift`). +/// Nothing links the two spellings, so this writes the same bytes and asserts +/// the app sees a readable conversation — a storage change that breaks the +/// Swift fixture fails here first, in seconds rather than in a minute of +/// `xcodebuild`. +#[test] +#[serial(env_vars)] +fn the_ui_test_fixture_layout_is_readable() { + let tmp = tempdir().unwrap(); + let _guard = EnvGuard::redirect(&tmp.path().join("user-data")); + + let root = tmp.path().join("my-workspace"); + let store = root.join(".jp"); + std::fs::create_dir_all(&store).unwrap(); + std::fs::write( + store.join(".id"), + "DO NOT EDIT THIS FILE! IT IS AUTO-GENERATED BY JP.\nuitst\n", + ) + .unwrap(); + + // Named by the bare ID, with no title slug: the loader finds a conversation + // by the ID prefix, so the fixture is spared reproducing the slug rule. + let dir = store.join("conversations").join(CONVERSATION_ID); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("metadata.json"), + r#"{"title":"Reading list","last_activated_at":"2024-09-01 09:00:00.0"}"#, + ) + .unwrap(); + std::fs::write(dir.join("base_config.json"), UI_TEST_BASE_CONFIG).unwrap(); + std::fs::write( + dir.join("events.json"), + r#"[{"timestamp":"2024-09-01 09:00:00.0","type":"chat_request","author":"Jean","content":"What is on the reading list?"},{"timestamp":"2024-09-01 09:00:01.0","type":"chat_response","message":"Three books and a paper."}]"#, + ) + .unwrap(); + + assert_eq!( + conversations_json(&root), + r#"[{"id":"17251488000","title":"Reading list","last_activated_at":"2024-09-01T09:00:00Z","events_count":2}]"# + ); + + assert_eq!( + events_json(&root, CONVERSATION_ID), + r#"[{"index":0,"events":[{"type":"user_message","timestamp":"2024-09-01T09:00:00Z","author":"Jean","text":"What is on the reading list?"},{"type":"assistant_message","timestamp":"2024-09-01T09:00:01Z","text":"Three books and a paper."}]}]"# + ); +} + +#[test] +fn events_reports_a_null_handle() { + let id = CString::new(CONVERSATION_ID).unwrap(); + + // SAFETY: null is the one handle value the contract admits without an open + // workspace behind it; the entry point checks for it before dereferencing. + let json = unsafe { jp_workspace_events(ptr::null_mut(), id.as_ptr(), ptr::null_mut()) }; + + assert!(json.is_null()); + assert_eq!( + take_last_error(), + Some("workspace handle is null".to_owned()) + ); +} + +#[test] +fn open_reports_a_null_path() { + // SAFETY: null is the one pointer value the contract admits without a + // string behind it; the entry point checks for it before dereferencing. + let ws = unsafe { jp_workspace_open(ptr::null()) }; + + assert!(ws.is_null()); + assert_eq!(take_last_error(), Some("path is null".to_owned())); +} + +#[test] +fn conversations_reports_a_null_handle() { + // SAFETY: null is the one handle value the contract admits without an open + // workspace behind it; the entry point checks for it before dereferencing. + let json = unsafe { jp_workspace_conversations(ptr::null_mut(), ptr::null_mut()) }; + + assert!(json.is_null()); + assert_eq!( + take_last_error(), + Some("workspace handle is null".to_owned()) + ); +} + +/// The error slot is emptied by reading it, so a later success is not reported +/// as the earlier failure. +#[test] +fn last_error_is_taken_not_copied() { + // SAFETY: see `open_reports_a_null_path` — a null path is handled, not + // dereferenced. + let ws = unsafe { jp_workspace_open(ptr::null()) }; + assert!(ws.is_null()); + + assert_eq!(take_last_error(), Some("path is null".to_owned())); + assert_eq!(take_last_error(), None); +} + +/// Releasing null is a no-op, so callers need no null checks of their own. +#[test] +fn freeing_null_is_a_no_op() { + // SAFETY: both entry points document null as accepted and return early on + // it, which is exactly the behavior under test. + unsafe { + jp_workspace_close(ptr::null_mut()); + jp_string_free(ptr::null_mut()); + } +} diff --git a/crates/jp_ffi/src/timing.rs b/crates/jp_ffi/src/timing.rs new file mode 100644 index 000000000..d59e49990 --- /dev/null +++ b/crates/jp_ffi/src/timing.rs @@ -0,0 +1,98 @@ +//! How long the work inside one call took. +//! +//! Measuring happens here; writing does not. +//! The caller keeps one trace file and one ordering, and a writer on this side +//! of the boundary would produce a second timeline to be reconciled with the +//! first afterwards. +//! +//! Durations rather than timestamps, for the same reason: two clocks that +//! nearly agree are worse than one, so the caller places these against the +//! clock it already reads. + +use std::{ + ffi::{CString, c_char}, + ptr, + time::{Duration, Instant}, +}; + +use serde::Serialize; + +/// One measured piece of work. +#[derive(Debug, Serialize)] +struct Span { + /// What the work is called. + name: &'static str, + + /// How long it took, in milliseconds to the microsecond. + duration_ms: f64, +} + +/// What one call measured, in the order the work finished. +#[derive(Debug, Default)] +pub(crate) struct Timings { + spans: Vec, +} + +impl Timings { + /// Run `work`, recording how long it took under `name`. + pub(crate) fn measure(&mut self, name: &'static str, work: impl FnOnce() -> T) -> T { + let started = Instant::now(); + let value = work(); + self.record(name, started.elapsed()); + value + } + + /// Record work that was timed elsewhere. + pub(crate) fn record(&mut self, name: &'static str, elapsed: Duration) { + self.spans.push(Span { + name, + duration_ms: milliseconds(elapsed), + }); + } + + /// The spans as the JSON array the caller decodes. + /// + /// `None` when the array cannot be built, which leaves the caller without + /// timings for the call rather than without its result. + pub(crate) fn to_c_string(&self) -> Option { + CString::new(serde_json::to_string(&self.spans).ok()?).ok() + } +} + +/// Hand the caller its timings, if it asked for them. +/// +/// A non-null `slot` is always written, with null standing for timings that +/// could not be built, so a caller never reads back whatever it happened to +/// declare the variable with. +/// +/// A written pointer is released with `jp_string_free`, like every other string +/// this library returns. +/// +/// # Safety +/// +/// `slot` must be null, or point to a writable `*mut c_char`. +pub(crate) unsafe fn publish(slot: *mut *mut c_char, timings: &Timings) { + if slot.is_null() { + return; + } + + let json = timings + .to_c_string() + .map_or(ptr::null_mut(), CString::into_raw); + + // SAFETY: `slot` is non-null (checked above) and writable per this + // function's contract, so the write lands in the caller's variable. + unsafe { slot.write(json) }; +} + +/// A duration in milliseconds, rounded to the microsecond. +/// +/// The resolution the caller records its own intervals at, so a span from this +/// side and the one around it on the other read in the same units. +fn milliseconds(duration: Duration) -> f64 { + (duration.as_secs_f64() * 1_000_000.0).round() / 1000.0 +} + +#[cfg(test)] +#[path = "timing_tests.rs"] +mod tests; diff --git a/crates/jp_ffi/src/timing_tests.rs b/crates/jp_ffi/src/timing_tests.rs new file mode 100644 index 000000000..fd9f79df1 --- /dev/null +++ b/crates/jp_ffi/src/timing_tests.rs @@ -0,0 +1,88 @@ +use std::ffi::CStr; + +use super::*; + +/// A timings payload, character for character. +/// +/// Pinned here and in `apps/macos/Tests/WorkspaceReaderTests.swift`, which +/// decodes this exact string. +/// Nothing else checks that the two sides agree on the shape: if one of these +/// two literals is edited alone, the other test is what says so. +const TIMINGS_JSON: &str = r#"[{"name":"storage.read","duration_ms":1.234},{"name":"deserialize","duration_ms":84.219},{"name":"serialize","duration_ms":3.0}]"#; + +#[test] +fn spans_serialize_in_the_order_they_were_recorded() { + let mut timings = Timings::default(); + timings.record("storage.read", Duration::from_micros(1_234)); + timings.record("deserialize", Duration::from_micros(84_219)); + timings.record("serialize", Duration::from_millis(3)); + + assert_eq!( + timings.to_c_string().unwrap().to_str().unwrap(), + TIMINGS_JSON + ); +} + +/// A call that failed before doing any of the work it measures still reports an +/// array, so the caller decodes one shape rather than two. +#[test] +fn no_spans_serialize_as_an_empty_array() { + assert_eq!( + Timings::default().to_c_string().unwrap().to_str().unwrap(), + "[]" + ); +} + +/// Sub-microsecond work is rounded, not truncated to zero: a span that reports +/// `0` is indistinguishable from one that never ran. +#[test] +fn durations_round_to_the_microsecond() { + let mut timings = Timings::default(); + timings.record("nanoseconds", Duration::from_nanos(1_499)); + + assert_eq!( + timings.to_c_string().unwrap().to_str().unwrap(), + r#"[{"name":"nanoseconds","duration_ms":0.001}]"# + ); +} + +#[test] +fn measure_records_the_work_it_wraps() { + let mut timings = Timings::default(); + let value = timings.measure("work", || 7); + + assert_eq!(value, 7); + assert_eq!(timings.spans.len(), 1); + assert_eq!(timings.spans[0].name, "work"); +} + +#[test] +fn publishing_to_a_null_slot_is_a_no_op() { + // SAFETY: null is the one slot value the contract admits without a + // variable behind it, and `publish` checks for it before writing. + unsafe { publish(ptr::null_mut(), &Timings::default()) }; +} + +#[test] +fn publishing_fills_the_slot_with_a_string_the_caller_frees() { + let mut timings = Timings::default(); + timings.record("serialize", Duration::from_millis(3)); + + let mut slot: *mut c_char = ptr::null_mut(); + + // SAFETY: `slot` is a live, writable `*mut c_char` that outlives the call. + unsafe { publish(&raw mut slot, &timings) }; + + assert!(!slot.is_null()); + + // SAFETY: `slot` was just written with a `CString::into_raw` pointer, so it + // is NUL-terminated and reclaiming it as a `CString` pairs the allocation + // with its original allocator. It is not used after being freed. + unsafe { + assert_eq!( + CStr::from_ptr(slot).to_str().unwrap(), + r#"[{"name":"serialize","duration_ms":3.0}]"# + ); + drop(CString::from_raw(slot)); + } +} diff --git a/justfile b/justfile index 7dba3a2d6..61787d0cf 100644 --- a/justfile +++ b/justfile @@ -3,6 +3,7 @@ set fallback # see: bacon_version := "3.23.0" binstall_version := "1.20.0" +cbindgen_version := "0.29.4" deny_version := "0.19.9" expand_version := "1.0.123" insta_version := "1.48.0" @@ -127,6 +128,59 @@ stage-and-commit: _install-jp build-changelog: (_install "jilu@" + jilu_version) @jilu +# Build the static library and C header that the macOS app links against, and +# stage both where the Xcode project expects them. +# +# Xcode runs this from a build phase, so `just` stays the single entry point for +# building the Rust side rather than Xcode growing a competing one. +# +# PROFILE is a cargo profile directory name (`debug`, `release`, ...). +[group('build')] +build-ffi PROFILE="debug": (_install "cbindgen@" + cbindgen_version) + #!/usr/bin/env sh + set -eu + + if ! which jq >/dev/null 2>&1; then + echo "jq not found. Install it with: brew install jq" >&2 + exit 1 + fi + + # The `dev` profile builds into a `debug` directory, so the profile flag and + # the output directory disagree for that one case. + if [ "{{PROFILE}}" = "debug" ]; then + cargo build {{quiet_flag}} --package jp_ffi + else + cargo build {{quiet_flag}} --package jp_ffi --profile "{{PROFILE}}" + fi + + # Ask cargo where it writes rather than assuming `./target`. The target + # directory is redirectable, and sibling git worktrees here share one that + # sits outside the checkout entirely. + target_dir=$(cargo metadata --format-version=1 --no-deps | jq -r '.target_directory') + lib="$target_dir/{{PROFILE}}/libjp_ffi.a" + + if [ ! -f "$lib" ]; then + echo "cargo did not produce $lib" >&2 + exit 1 + fi + + # Stage into a fixed, checkout-local directory. Xcode's search paths are + # static build settings, so they need one location that does not move with + # the developer's cargo configuration. + out="apps/macos/.build/{{PROFILE}}" + mkdir -p "$out/include" + + # A debug staticlib bundles every dependency, so skip the copy when the + # staged one is already current. + if [ ! -f "$out/libjp_ffi.a" ] || [ "$lib" -nt "$out/libjp_ffi.a" ]; then + cp "$lib" "$out/libjp_ffi.a" + fi + + cbindgen --config crates/jp_ffi/cbindgen.toml --crate jp_ffi --output "$out/include/jp_ffi.h" + + echo "library: $out/libjp_ffi.a" >&2 + echo "header: $out/include/jp_ffi.h" >&2 + [group('profile')] [positional-arguments] profile-heap *ARGS: From e74d6e8d5c7d9711fe661a23feebdcd7a196f5f6 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Wed, 19 Aug 2026 08:11:18 +0200 Subject: [PATCH 5/8] feat(macos): Add a native app for browsing conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversations are readable from the terminal and, through the plugin system, from a browser. Neither suits browsing: scrolling back through weeks of turns, skimming several conversations side by side, or reading on the machine where the work happened. This is a real Mac app for that, with native windows, tabs, menus and state restoration rather than a web view in a window. It is a viewer. It opens a workspace and reads conversations, never writes them, and has no compose field. A window shows the conversation list beside the transcript, split by a divider whose position is restored across launches, and opening any directory inside a workspace opens that workspace, matching how `jp` itself walks up to find `.jp`. The scene is a plain `WindowGroup`. Keying it by workspace path made a window's identity its workspace, so ⌘N on a workspace already on screen brought that window forward instead of opening one, and ⌘T had nothing to duplicate. Each window decides which workspace it shows and keeps that choice in its own scene storage. Timestamps arrive as text and stay that way. The Rust side emits a fractional-seconds part whenever the stored value has one, and `JSONDecoder`'s `.iso8601` strategy rejects those, so a `Date` on the Swift struct would decode the whole-second case and fail on every real workspace. `ConversationDate` parses at the point of display instead. `ConversationSummary` and the event payloads are hand-maintained mirrors of Rust types with no compiler checking that they agree, so each one has a test pinning the exact JSON it decodes. The timings payload is pinned on both sides — `WorkspaceReaderTests` and `jp_ffi`'s `timing_tests` hold the same literal — and if one is edited alone the other is what says so. `project.yml` is the source of truth for the Xcode project, which is generated by `just gen-app` rather than committed, so targets, build settings and the Rust build phase stay reviewable. Swift 6 language mode with complete concurrency checking is on and warnings are errors, holding the app to the bar the Rust side is held to. Debug builds carry `dwarf-with-dsym` and disable Xcode's debug dylib split, because a profiler pointed at the bundle's executable otherwise reads a launcher stub whose UUID matches nothing in the trace. `just run-app` launches it in the foreground with output attached, and `just open-app` goes through LaunchServices for the AppKit behavior that only a normally registered launch produces. Signed-off-by: Jean Mertz --- apps/macos/.swift-format | 29 + apps/macos/AFFORDANCES.md | 276 +++++++++ apps/macos/QA.md | 344 ++++++++++++ apps/macos/Sources/AccessibilityID.swift | 79 +++ .../Sources/Bridging/JPFFI-Bridging-Header.h | 6 + apps/macos/Sources/ConversationDate.swift | 101 ++++ apps/macos/Sources/ConversationEvent.swift | 91 +++ apps/macos/Sources/ConversationFilter.swift | 40 ++ .../Sources/ConversationHistoryView.swift | 160 ++++++ apps/macos/Sources/ConversationList.swift | 132 +++++ apps/macos/Sources/ConversationOrder.swift | 42 ++ apps/macos/Sources/ConversationRef.swift | 40 ++ apps/macos/Sources/ConversationRow.swift | 175 ++++++ apps/macos/Sources/ConversationTurn.swift | 61 ++ apps/macos/Sources/ConversationWindow.swift | 41 ++ apps/macos/Sources/DebugSpaces.swift | 57 ++ apps/macos/Sources/DebugState.swift | 158 ++++++ apps/macos/Sources/JPApp.swift | 171 ++++++ .../Sources/ListSelectionHighlight.swift | 79 +++ apps/macos/Sources/Markdown.swift | 460 +++++++++++++++ apps/macos/Sources/MarkdownStyle.swift | 104 ++++ apps/macos/Sources/RecentWorkspaces.swift | 82 +++ apps/macos/Sources/RecentsStore.swift | 126 +++++ apps/macos/Sources/SearchField.swift | 89 +++ apps/macos/Sources/Theme.swift | 112 ++++ apps/macos/Sources/Trace.swift | 492 ++++++++++++++++ apps/macos/Sources/TranscriptDocument.swift | 81 +++ apps/macos/Sources/TranscriptTextView.swift | 382 +++++++++++++ apps/macos/Sources/WindowButtons.swift | 132 +++++ apps/macos/Sources/WorkspaceModel.swift | 132 +++++ apps/macos/Sources/WorkspaceReader.swift | 243 ++++++++ apps/macos/Sources/WorkspaceSession.swift | 76 +++ apps/macos/Sources/WorkspaceWindow.swift | 527 ++++++++++++++++++ apps/macos/Tests/AccessibilityIDTests.swift | 61 ++ apps/macos/Tests/ClipboardPolicyTests.swift | 71 +++ apps/macos/Tests/ConversationDateTests.swift | 137 +++++ .../macos/Tests/ConversationFilterTests.swift | 105 ++++ apps/macos/Tests/ConversationOrderTests.swift | 110 ++++ apps/macos/Tests/ConversationRefTests.swift | 55 ++ .../Tests/ConversationSummaryTests.swift | 132 +++++ apps/macos/Tests/ConversationTurnTests.swift | 133 +++++ apps/macos/Tests/DebugStateTests.swift | 126 +++++ apps/macos/Tests/MarkdownTests.swift | 277 +++++++++ apps/macos/Tests/RecentWorkspacesTests.swift | 122 ++++ apps/macos/Tests/RecentsStoreTests.swift | 102 ++++ apps/macos/Tests/TestSandbox.swift | 87 +++ apps/macos/Tests/ThemeTests.swift | 124 +++++ apps/macos/Tests/TraceTests.swift | 173 ++++++ .../macos/Tests/TranscriptTextViewTests.swift | 77 +++ apps/macos/Tests/WorkspaceModelTests.swift | 108 ++++ apps/macos/Tests/WorkspaceReaderTests.swift | 258 +++++++++ apps/macos/Tests/WorkspaceWindowTests.swift | 154 +++++ apps/macos/project.yml | 158 ++++++ justfile | 117 ++++ 54 files changed, 7807 insertions(+) create mode 100644 apps/macos/.swift-format create mode 100644 apps/macos/AFFORDANCES.md create mode 100644 apps/macos/QA.md create mode 100644 apps/macos/Sources/AccessibilityID.swift create mode 100644 apps/macos/Sources/Bridging/JPFFI-Bridging-Header.h create mode 100644 apps/macos/Sources/ConversationDate.swift create mode 100644 apps/macos/Sources/ConversationEvent.swift create mode 100644 apps/macos/Sources/ConversationFilter.swift create mode 100644 apps/macos/Sources/ConversationHistoryView.swift create mode 100644 apps/macos/Sources/ConversationList.swift create mode 100644 apps/macos/Sources/ConversationOrder.swift create mode 100644 apps/macos/Sources/ConversationRef.swift create mode 100644 apps/macos/Sources/ConversationRow.swift create mode 100644 apps/macos/Sources/ConversationTurn.swift create mode 100644 apps/macos/Sources/ConversationWindow.swift create mode 100644 apps/macos/Sources/DebugSpaces.swift create mode 100644 apps/macos/Sources/DebugState.swift create mode 100644 apps/macos/Sources/JPApp.swift create mode 100644 apps/macos/Sources/ListSelectionHighlight.swift create mode 100644 apps/macos/Sources/Markdown.swift create mode 100644 apps/macos/Sources/MarkdownStyle.swift create mode 100644 apps/macos/Sources/RecentWorkspaces.swift create mode 100644 apps/macos/Sources/RecentsStore.swift create mode 100644 apps/macos/Sources/SearchField.swift create mode 100644 apps/macos/Sources/Theme.swift create mode 100644 apps/macos/Sources/Trace.swift create mode 100644 apps/macos/Sources/TranscriptDocument.swift create mode 100644 apps/macos/Sources/TranscriptTextView.swift create mode 100644 apps/macos/Sources/WindowButtons.swift create mode 100644 apps/macos/Sources/WorkspaceModel.swift create mode 100644 apps/macos/Sources/WorkspaceReader.swift create mode 100644 apps/macos/Sources/WorkspaceSession.swift create mode 100644 apps/macos/Sources/WorkspaceWindow.swift create mode 100644 apps/macos/Tests/AccessibilityIDTests.swift create mode 100644 apps/macos/Tests/ClipboardPolicyTests.swift create mode 100644 apps/macos/Tests/ConversationDateTests.swift create mode 100644 apps/macos/Tests/ConversationFilterTests.swift create mode 100644 apps/macos/Tests/ConversationOrderTests.swift create mode 100644 apps/macos/Tests/ConversationRefTests.swift create mode 100644 apps/macos/Tests/ConversationSummaryTests.swift create mode 100644 apps/macos/Tests/ConversationTurnTests.swift create mode 100644 apps/macos/Tests/DebugStateTests.swift create mode 100644 apps/macos/Tests/MarkdownTests.swift create mode 100644 apps/macos/Tests/RecentWorkspacesTests.swift create mode 100644 apps/macos/Tests/RecentsStoreTests.swift create mode 100644 apps/macos/Tests/TestSandbox.swift create mode 100644 apps/macos/Tests/ThemeTests.swift create mode 100644 apps/macos/Tests/TraceTests.swift create mode 100644 apps/macos/Tests/TranscriptTextViewTests.swift create mode 100644 apps/macos/Tests/WorkspaceModelTests.swift create mode 100644 apps/macos/Tests/WorkspaceReaderTests.swift create mode 100644 apps/macos/Tests/WorkspaceWindowTests.swift create mode 100644 apps/macos/project.yml diff --git a/apps/macos/.swift-format b/apps/macos/.swift-format new file mode 100644 index 000000000..88c1c32f0 --- /dev/null +++ b/apps/macos/.swift-format @@ -0,0 +1,29 @@ +{ + "version": 1, + "lineLength": 96, + "indentation": { "spaces": 4 }, + "respectsExistingLineBreaks": true, + "lineBreakBeforeEachArgument": false, + "prioritizeKeepingFunctionOutputTogether": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": true, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "DontRepeatTypeInStaticProperties": true, + "NeverForceUnwrap": true, + "NeverUseForceTry": true, + "NeverUseImplicitlyUnwrappedOptionals": true, + "NoLeadingUnderscores": true, + "OmitExplicitReturns": false, + "OneCasePerLine": true, + "OnlyOneTrailingClosureArgument": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "UseEarlyExits": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": true, + "ValidateDocumentationComments": true + } +} diff --git a/apps/macos/AFFORDANCES.md b/apps/macos/AFFORDANCES.md new file mode 100644 index 000000000..2461a0feb --- /dev/null +++ b/apps/macos/AFFORDANCES.md @@ -0,0 +1,276 @@ +# Affordance map + +What the JP reader responds to, and what each response is meant to do. This is +the contract phase 4 of [RFD 099] holds the app to; the QA checklist beside it +([`QA.md`](QA.md)) is how it gets checked by hand. + +Anything listed here that is not implemented says so. + +## Menus + +| Menu | Item | Shortcut | Does | +| ------- | ------------------- | -------- | ----------------------------------------------------------- | +| JP | About JP | | Standard. | +| JP | Quit JP | ⌘Q | Standard. | +| File | New Window | ⌘N | Opens a window on the most recently opened workspace. | +| File | Open Workspace… | ⌘O | Directory chooser; any directory inside a workspace opens it. | +| File | Open Recent ▸ | | Workspaces opened before, newest first. | +| File | Open Recent ▸ Clear | | Empties the list. | +| File | Close | ⌘W | Closes the window, or the frontmost tab. | +| Edit | Copy Link | ⇧⌘C | Copies the selected conversation's `jp://` URI. | +| View | Hide/Show Sidebar | ⌃⌘S | Hides or shows the conversation list. | +| View | Show All Tabs | ⇧⌘\ | Standard. | +| Window | Show Previous Tab | ⌃⇧⇥ | Standard. | +| Window | Merge All Windows | | Standard. | + +`File ▸ Open Workspace` and `Open Recent` act on the frontmost window rather than +opening a new one, so they are disabled when no window has focus. ⌘N first. + +**New Window opens the last workspace rather than an empty window.** A window +with no workspace can do nothing but ask for one, and the overwhelmingly likely +answer is the workspace you were just reading. This is also what makes ⌘W on the +last tab acceptable: closing is cheap because reopening is one keystroke and lands +you back where you were. + +## Conversation list + +| Input | Does | +| --------------------- | ---------------------------------------------------------- | +| Click | Selects; the transcript follows. | +| Double-click | Opens the conversation in its own window. | +| ↑ / ↓ | Moves the selection. | +| Escape | Clears the selection; the transcript pane empties. | +| Right-click | Context menu: Open in New Window, Copy Link. | +| Drag | Drags the conversation out as a `jp://` URI. | +| Type in the filter | Narrows the list to titles containing what was typed. | +| Click the clear button | Empties the filter box, restoring the whole list. | +| Drag the divider | Resizes the sidebar, between 220 and 480 points. | + +The whole row is a click target, including the padding around the text. + +A row shows the conversation's title over its date and event count, and a pinned +conversation carries a pin glyph in the accent colour beside them. Rows are a +fixed height, sized for a title of two lines: a list has to know its total +content height to size a scroll bar, and variable-height rows mean measuring +every row rather than the visible ones. + +A row draws its own background, its selection and the line under it. The list +contributes none of the three: its separators run edge to edge and its selection +is a full-width fill in the system accent colour. The table view's own selection +drawing is turned off outright — see `Sources/ListSelectionHighlight.swift` — +because nothing drawn above it hides it reliably. + +The selected row is a rounded fill with a thick accent bar down its leading edge, +both clipped to the same shape. **No line is drawn against a selected row**, above +or below it, so that fill is not cut across at either end. + +A title wraps to a second line before it truncates. The row is a fixed height, so +the space a one-line title leaves is simply empty — which is where Bear puts a +content preview, and where one would go. + +**A line above the first row appears only once the list is scrolled away from the +top.** At rest there is nothing to separate it from; scrolled, it separates the +search field from the rows passing under it. + +**Pinned conversations sort above the rest**, keeping the library's +most-recently-active order inside each group, so pinning lifts one conversation +and moves nothing else. + +The filter's clear button is always there, whether or not there is anything to +clear. A control that comes and goes with what has been typed moves the text's +right edge as it appears. + +Selection, double-click and the context menu are all the list's own, through +`contextMenu(forSelectionType:primaryAction:)`, rather than gestures attached to +each row. That is both why they behave like every other Mac list and why +scrolling a large sidebar stays cheap: a per-row context menu is rebuilt for +every row the list realizes. + +The context menu acts on the whole selection, so Open in New Window on three +selected conversations opens three windows and Copy Link copies three URIs, one +per line. + +Edit ▸ Copy Link copies the same URI for the selected conversation, and is what +reaches it without a pointing device. It takes ⇧⌘C rather than ⌘C so the +transcript keeps the shortcut for copying selected text. It is greyed out while +nothing is selected. + +Escape clears the selection, which is the only way back to an empty transcript +pane once a conversation has been read. It belongs to the list, so Escape while +the filter box has focus still means "clear what I typed". + +**Copy and drag produce a `jp://` URI**, which is the form JP itself uses to +reference a conversation, so pasting into a terminal or a query is useful. Not a +markdown file — that is [noted as future work](#not-implemented). + +## Transcript + +| Input | Does | +| -------------- | ----------------------------------------------------------- | +| Select text | Selects across the whole transcript, not just one message. | +| ⌘C | Copies the selected text. | +| Scroll | Scrolls; the scroll bar reflects the real height. | +| Drag the window edge | Re-wraps the text as the window moves, at any scroll position. | + +**The whole conversation is one text view.** Not a stack of one view per +message: a text view lays out what its viewport needs and re-wraps +incrementally, where a stack of views each measure themselves and a width change +costs the sum of them. That is also why selection runs across messages rather +than stopping at one, and why the scroll bar can state a real height instead of +an estimate. + +**Only messages are shown.** A user message and an assistant message each render +under the name of whoever said it. Tool calls, reasoning, inquiries, config +changes and turn markers have no prose to show and never cross the FFI boundary +— the library leaves them out rather than the app filtering them. Tool calls, +attachments and reasoning display are Non-Goals of RFD 099. + +**Messages are grouped into turns.** A turn is one user request through the +assistant's final answer to it. Where the boundaries fall is decided on the +library side, because the rules are not recoverable from the events alone: there +is an implicit leading turn, and a marker that opens a turn only sometimes. The +boundary is drawn as space — the gap above the first message of a turn is wider +than the gap between two messages inside one. + +**Block markdown renders**: headings, ordered and unordered lists with nesting, +fenced code blocks, block quotes, thematic breaks, and the inline set (bold, +italic, `code`, links, strikethrough). Soft line breaks reflow into the +paragraph, per CommonMark, which is what the terminal renderer does too. + +**Tables lay out in columns**, one row per line, each cell carried to its column +by a tab stop. A column takes the alignment the source declared — `---:` in the +separator row right-aligns it — and the header row is bold. Column width is +fixed rather than measured: measuring means laying every cell out at a width the +container has not settled on, and redoing it on every resize, for a reader +rather than an editor. + +Deliberately not `NSTextTable`, which would give real cell boxes and is a +TextKit 1 feature — putting one in the string drags the text view off TextKit 2 +silently, taking viewport layout with it. + +There is no reading-width cap. One text view re-wraps cheaply enough that +capping the column bought nothing, and the cap that used to be here never +engaged on a wide display anyway. + +The text view runs on **TextKit 1**, with contiguous layout, so the document +height is exact and the scroll bar states it rather than estimating. That is the +reason for the choice: an honest scroll bar was a goal, and TextKit 2's height is +an estimate that refines as it scrolls, which moves the knob under the pointer. + +It costs real work. The same ten programmatic resizes measured 438 samples here +against 155 on TextKit 2 for a 29-event conversation, and 412 against 355 for a +167-event one — so TextKit 1 is flat with document size where TextKit 2 scales, +and the gap narrows as conversations grow. Revisit if a long conversation starts +feeling slow to resize; `Sources/TranscriptTextView.swift` has it behind one +named constant. + +**The text container's width is set by hand on every frame of a drag.** A text +view normally hands its width to the container it is tracked by, and does not do +that while a live resize is in progress — the container keeps the width the drag +started from until the mouse comes up. Nothing then invalidates layout, and the +view faithfully redraws lines wrapped to a width the window no longer has. See +`Sources/TranscriptTextView.swift`; `TranscriptReflowTests` holds it. + +## Windows + +- **No title bar.** A workspace window's content runs to the top of the window, + with the close, minimize and zoom buttons over the sidebar's top-left corner and + the search field beside them. The window still carries a title — it is what the + Window menu lists and what an external driver addresses it by — but nothing + displays it. +- **The window buttons are moved.** macOS puts them six points in and centres them + fourteen points down, which is the middle of a title bar this window does not + have. They are placed against the search field instead, and put back whenever + AppKit lays the title bar out afresh. There is no supported way to ask for this: + a title bar grows to fit a toolbar, and a toolbar would span the whole window. + See `Sources/WindowButtons.swift`. +- **No sidebar toggle button**, because there is no title bar to put one in. + View ▸ Hide Sidebar (⌃⌘S) is how the sidebar is hidden and brought back. +- **The window holds its two panes itself**, rather than in a + `NavigationSplitView`. `NSSplitView` draws a translucent divider over whatever + is behind it and offers no way to change its colour or width, which left the + line between the panes two pixels of two different greys that shifted with the + content underneath. The divider is now the app's own: two points, one colour, + and draggable through a wider invisible strip around it. +- **Restored per window**: the workspace, the selected conversation, the + transcript's scroll position, the sidebar's width, and whether the sidebar is + showing. +- **One window per workspace**, keyed by the workspace path. Opening the same + workspace twice reaches the same window rather than making a second one, which + is why the path is canonicalized before it is used as the key. +- **A conversation can be pulled into its own window** by double-clicking. That + window carries the workspace path with it, so it can be restored at launch with + no workspace window open. +- **Native tabbing**, through Window ▸ Merge All Windows and the tab bar. + +## Accessibility + +- The conversation list is labelled `Conversations`. +- Each row is one accessibility element combining the title and event count, + rather than two unrelated fragments. A pinned row appends `, pinned`. +- A row's label leaves out the date it displays. The date is relative for + anything active today, so a label carrying it would read differently one + minute later and could not be pinned by a test. +- The transcript is selectable text, so VoiceOver reads messages as text. +- **There is no element per message.** The conversation is one text area, and its + value is every message it is showing. A driver addresses `transcript.text` and + reads that value; a test asserting on what is on screen compares it whole, + which catches a missing speaker label or a duplicated message that a search for + one phrase would not. + +### Identifiers + +Every element an external driver has to find carries an accessibility +identifier, so it can be reached without matching display text. The names live +in `Sources/AccessibilityID.swift` and are pinned by `AccessibilityIDTests`. + +| Identifier | Element | +| ------------------------------ | -------------------------------------- | +| `sidebar.state.loading` | Spinner while the workspace is read. | +| `sidebar.filter` | The box that narrows the list. | +| `sidebar.filter.clear` | The button that empties the filter box, always present. | +| `sidebar.list` | The conversation list. | +| `sidebar.row.` | One row. | +| `sidebar.state.nomatches` | Message shown when a filter matches none. | +| `sidebar.state.unavailable` | Message shown instead of a list. | +| `transcript.state.loading` | Spinner while a conversation is read. | +| `transcript.scroll` | The scrolling transcript. | +| `transcript.text` | The text the transcript is drawn as. | +| `transcript.state.unavailable` | Message shown instead of a transcript. | + +A row is named by the conversation's ID, so retitling a conversation does not +move it. There is no `sidebar.state.loaded` or `transcript.state.loaded`: a view +carries one identifier, and `sidebar.list` and `transcript.scroll` exist only in +that state, so they are the predicate. + +## Not implemented + +Named here so the gaps are visible rather than discovered. + +- **Drag produces a URI, not a markdown file.** Dropping into Finder therefore + does nothing useful. Filed as future work; it needs a presentation-neutral + conversation-to-markdown projection, which RFD 099 lists under Non-Goals. +- **No drop targets.** Nothing accepts a dragged conversation, including other JP + windows. +- **No live updates.** A window loads its workspace once. Turns written by a + concurrent `jp query` are invisible until the workspace is reopened. This is + RFD 099's stated v0.1 behavior. +- **A table column is a fixed width**, so a cell longer than one wraps into the + next column's space rather than widening it. Real cell boxes need + `NSTextTable`, which is TextKit 1 only. +- **The pointer does not change over the pane divider.** Dragging it resizes the + sidebar from either side, and `ResizeCursorAreaTests` shows the view asks for the + horizontal-resize cursor — but the hosted `NSView` carrying that request sits + inside the `accessibilityElement` that publishes `window.divider`, and the + collapse appears to detach it. Moving the request outside that element restores + the cursor and stops the drag reaching the strip, so the two want opposite + orderings and the resize wins. Unresolved; the likely answer is hanging the + cursor rect off a view that is not inside the accessibility element at all. +- **No find bar.** ⌘F does nothing. The text view is one document, so a find + interaction would work across the whole conversation; it simply is not turned + on. +- **⌘C does not copy from the conversation list.** It did, through a per-row + `.copyable`, but that cost more in scrolling than the shortcut was worth. Edit ▸ + Copy Link (⇧⌘C) and right click ▸ Copy Link both do the same thing. + +[RFD 099]: ../../docs/rfd/099-native-macos-app-for-browsing-conversations.md diff --git a/apps/macos/QA.md b/apps/macos/QA.md new file mode 100644 index 000000000..34b0eb838 --- /dev/null +++ b/apps/macos/QA.md @@ -0,0 +1,344 @@ +# QA checklist + +The behavior in [`AFFORDANCES.md`](AFFORDANCES.md) that has to be checked against +a running app. + +Each item says who checks it: + +- **`JPUITests/`** — a committed test in `apps/macos/UITests`. CI runs the + whole bundle with `just test-app-ui`; while writing one, run it by name + through the `swift_test_ui` tool. Each test launches the app and takes the + screen, so `just test-app` and the `swift_test` tool leave them out. + + The names below are a convenience, not the index: `swift_test_ui` asks the + built bundle what it holds, so that is the list to trust. +- **Eyes** — needs a person, permanently. Smoothness, rendering, and anything + whose answer is "does this look right". +- **Not yet mechanized** — checked by hand today, and a candidate for a test. +- **`debug_app_profile`** — answered by driving the app and reading back what it + timed, rather than by a committed test. These are the items about cost, and + they are checked in counts (view-body evaluations, FFI calls) rather than in + milliseconds: a count is the same for the same steps, so it can be compared + against an earlier run, while a millisecond threshold would be met by a broken + build on a quiet machine and missed by a good one on a busy machine. + +For everything still checked by hand, run `just run-app` first. It launches in +the foreground with output attached to the terminal, so warnings and crashes are +visible while you work through this. + +The hand-run items should be checked against a workspace with a few hundred +conversations, not an empty one: several of these only misbehave at size. The +UI tests build their own three-conversation workspace, which is why the ones +that only fail at size stay with a person. + +A suite shares one launched app across its tests, because launching costs +seconds and the work under test costs milliseconds. A test that needs an app +nobody has touched launches its own and says why; none currently does. + +`swift_test_ui` stops a run at the first failure and closes the app it was +driving, because a broken app usually fails every test after the first one too +and each costs a second to find that out. `just test-app-ui` sets `CI`, which +turns that off: nobody is watching a CI run, and one run reporting everything +beats a first failure reported quickly. + +## Launch and console + +- [x] The window opens showing the workspace named by `JP_WORKSPACE` — + `JPUITests` launches every test this way, so any test passing proves it. +- [ ] **No `reentrant operation in its NSTableView delegate` warning**, at + launch, on selection, or on quit. **Eyes**, or `debug_app_snapshot`: a UI + test cannot read the app's console, because `testmanagerd` launches the + app and keeps its output. `selectingKeepsTheInjectedMenuItems` covers the + damage that warning reports, but not the warning. +- [ ] No other warnings or exceptions in the terminal. **Eyes**, same reason. + +## Conversation list + +- [x] The window carries the workspace directory name as its title, and nothing + counting conversations beside it — + `ConversationListTests/namesTheWorkspace`. +- [ ] **Nothing displays that title, and there is no strip of chrome above the + transcript**: the window buttons sit over the sidebar's top-left corner with + the search field beside them, and there is no sidebar toggle button. + **Eyes**: the test above proves the title is carried, not that it is hidden. +- [x] View ▸ Hide Sidebar hides the conversation list and Show Sidebar brings it + back, which is the only way to now that the button is gone — + `ConversationListTests/viewMenuTogglesTheSidebar`. +- [ ] **⌃⌘S does the same as the menu item.** **Eyes**: the test above chooses + the item rather than pressing the key. +- [x] Conversations are ordered most recently active first — + `ConversationListTests/ordersByActivity`. +- [x] A pinned conversation sits above every unpinned one, whatever their + activity, and its row says it is pinned — + `PinnedConversationTests/pinnedSortsFirst`. +- [x] A row reads as its title and event count together — + `ConversationListTests/labelsRows`. +- [x] Typing in the filter box narrows the list, and the clear button beside it — + there whether or not anything has been typed — restores the whole list — + `ConversationListTests/filtersAndClears`. +- [ ] **The search field lines up with the window buttons**, its middle level + with theirs, and is about as tall as Bear's. **Eyes**: the field's text is an + accessibility element and is centred on the buttons, but the rounded box + around it is drawn and cannot be measured. +- [ ] **A pinned row shows the pin glyph in the accent colour**, to the left of + the date. **Eyes**: the test above proves the row is labelled pinned and + sorted first, not that anything is drawn. +- [ ] **A long title wraps to a second line and truncates there**, not after the + first. **Eyes**, or `debug_app_pixels`: the accessibility label carries the + whole title whatever is drawn, so the tree cannot see where it was cut, but a + scan down a row shows one text band or two. +- [ ] **The selected row carries a thick accent bar down its leading edge**, inside + the rounded fill rather than against the window's edge. **Eyes**, or a row + scan across the selection. +- [ ] **A line appears above the first row when the list is scrolled**, and goes + away at the top. **Eyes**. +- [ ] **The window buttons sit level with the search field**, about twenty points + in from the window's left edge. Their frames are in the accessibility tree, + so the centres can be compared against the field's without a screenshot; the + visible circles inside them cannot, and want a scan. +- [ ] **Dragging the divider is smooth** on a workspace of a thousand + conversations. **Eyes**: a synthesized drag needs the window frontmost, and + the cost is in SwiftUI's own re-evaluation rather than in anything the app + times. +- [ ] **A row's date reads the way the system locale writes one**: how long ago + for anything active today, `31 Jul` inside this year, `13 May 2024` before + that. **Eyes**: `ConversationDateTests` pins all three against a fixed + locale, which is not the reader's. +- [ ] **The list is the palette's, not the system's**: white rows on a white + sidebar in light appearance, `#1D1E20` in dark, separators inset to the + text rather than running edge to edge, and a selected row filled `#F4F5F7` + in an inset rounded rectangle. **Eyes**, in both appearances. `ThemeTests` + proves each colour resolves per appearance; only a screenshot says the list + is actually wearing them. +- [ ] **A selected row shows no trace of the system accent colour**, at the + moment of the click or after it. **Eyes**: the table view's selection + drawing is turned off through AppKit, and nothing outside the window can + see what a row is filled with. +- [ ] **The line between the sidebar and the transcript is one point of one + uniform colour**, the divider colour, top to bottom — two pixels on a retina + display. **Eyes**: the app draws this line itself, so its geometry is + checkable (the sidebar ends at 280 and the transcript starts at 281) but its + colour is not. +- [ ] **The pointer becomes the horizontal-resize cursor over that line.** + Currently it does not — see the Not implemented section of `AFFORDANCES.md`. + `ResizeCursorAreaTests` shows the view *asks* for the right cursor over the + right area, which is as far as a test reaches: a cursor is neither in the + accessibility tree nor in a screenshot. The test passing while the pointer + stays an arrow is the gap, and is why this line is unchecked. +- [x] **Dragging that line resizes the sidebar**, from either side of it — driven + against `window.divider` with `debug_app_drive`'s `drag` step, starting on the + right half, and the divider's frame moved by exactly the distance dragged. + Approaching from the transcript side used to do nothing at all, because the + pane is a later sibling and so in front of the grab strip. +- [ ] The sidebar stops at 220 and 480, and its width survives closing and + reopening the window. **Not yet mechanized**; the drag above reaches it now. +- [ ] **The search field is a rounded box with a magnifier inside it** and no + focus ring when it takes focus. **Eyes**. +- [ ] **The transcript sits on the editor background**, with prose in the body + colour and speaker names in the secondary one. **Eyes**, in both + appearances. +- [x] A single click selects that row, and the transcript follows — + `ConversationListTests/clickSelects`. +- [x] A click in the empty space beside the title selects the row too — + `ConversationListTests/clickBesideTitleSelects`. +- [x] ↑ and ↓ move the selection, and the transcript follows — + `ConversationListTests/arrowKeysMoveSelection`. +- [x] A double-click opens the conversation in a new window, and that window + shows the conversation rather than an empty pane — + `ConversationListTests/doubleClickOpensAWindow`. +- [x] Right-click ▸ Open in New Window does the same — + `ConversationListTests/contextMenuOpensAWindow`. +- [x] Right-click ▸ Copy Link puts `jp://` on the pasteboard — + `ConversationListTests/contextMenuCopiesTheURI`. +- [x] Escape clears the selection and empties the transcript pane — + `ConversationListTests/editCopyLinkFollowsTheSelection`. +- [x] Edit ▸ Copy Link (⇧⌘C) puts the selected conversation's URI on the + pasteboard, and is disabled while nothing is selected — + `ConversationListTests/editCopyLinkFollowsTheSelection`. +- [x] Selecting a conversation leaves the View and Window menus intact — Enter + Full Screen, Merge All Windows and the rest are items AppKit injects, and a + menu bar rebuilt at the wrong moment drops them — + `ConversationListTests/selectingKeepsTheInjectedMenuItems`. +- [ ] Dragging a row into a text editor inserts `jp://`. **Eyes**: the drop + target is another application, which is outside what a UI test can drive. +- [ ] **Scrolling the sidebar of a large workspace is smooth**, with no stutter + as rows come into view. **Eyes**. + +### Copy Link is checked without a clipboard being lost + +No test touches the *system* pasteboard. There is one of those and it belongs to +whoever is at the keyboard: a test that copies into it destroys what they had, +and saving and restoring around the test is not a fix, because a pasteboard item +can be a promise its owner fulfils lazily. + +So a debug build copies wherever `JP_DEBUG_PASTEBOARD` says, and each test +points the app at a private pasteboard of its own and reads that back. The +variable is compiled out of a release build, and an unset one means the system +pasteboard, so the shipped behaviour is the only behaviour a user can get. + +`ClipboardPolicyTests` scans `apps/macos/UITests` and fails on any spelling of +the system pasteboard, so this holds without anyone remembering it. + +### Multiple selection is not implemented + +`AFFORDANCES.md` says the context menu acts on the whole selection, so three +selected conversations copy three URIs and open three windows. The list binds a +single `String?`, so it never holds more than one conversation: a shift-click +does not extend the selection, and Copy Link on three rows copies one URI. + +No test asserts either behavior until this is settled, because one of the two +documents is wrong and it is not this checklist's job to pick. + +## Transcript + +- [x] Each message is shown under the name of whoever said it, and the whole + transcript is one text view rather than one view per message — + `ConversationListTests/clickSelects` compares the text view's whole value + against `Transcripts.configPipeline`, so a missing speaker label or a + duplicated message fails it. +- [x] Nothing but messages is shown: no tool calls, no reasoning, no turn + markers, no dimmed kind labels — the library never sends them. + `jp_ffi`'s `drops_every_event_with_no_prose_to_show` holds the boundary and + `ConversationTurnDecodingTests` holds the mirror. +- [ ] Block markdown looks right: heading sizes, list markers hanging outside + their text, wrapped list lines aligning under the first rather than under + the bullet, code blocks on their own background, quotes indented and dimmed. + **Eyes**: `MarkdownTests` pins every one of these as attributes, and none of + that says the result is legible. +- [ ] Paragraph spacing reads as paragraphs, and the gap between two turns is + clearly wider than the gap between two messages inside one. **Eyes**: the + numbers are pinned, the impression is not. +- [ ] A table reads as a table: columns line up down the rows, the header is + bold, and a column declared right-aligned has its numbers ending together. + **Eyes**: `MarkdownTests` pins the tab stops and their alignments, and none + of that says the columns look aligned. A cell longer than the fixed column + width will run into the next column — known, see `AFFORDANCES.md`. +- [ ] Text can be selected across message boundaries and copied with ⌘C. **Not + yet mechanized**; selection across the whole document is the point of one + text view, so a selection that stops at a message is a defect. +- [ ] **Scrolling a long conversation is smooth, and the scroll bar keeps a + constant size** rather than resizing or jumping as you scroll. **Eyes**. + Contiguous TextKit 1 layout is what makes the height exact, so a shifting + knob here means something changed about that — see `AFFORDANCES.md`. +- [x] **Dragging a window edge re-wraps the text as it moves, at any scroll + position** rather than waiting for the mouse to come up — + `TranscriptReflowTests/reflowsWhileDragging`, which drags a real window edge + and asserts the text container's width changed during the gesture. Verified + red by disabling the container write. +- [ ] **Resizing stays smooth on a long conversation.** Measure with + `debug_app_drive` using `reads: "none"` and a profile bracket, and compare + counts against another recording; a run with tree reads on measures the + reads instead. A resize evaluates no SwiftUI view bodies, so a climb here is + layout or text measurement, not re-rendering. + +## Performance + +Most of this is **eyes**: how the app feels under a load the fixture workspace +does not have, and no threshold in milliseconds separates a good build from a bad +one across machines. + +What is checkable is the work the app does rather than the time it takes. +`debug_app_drive` records when each step ran and `debug_app_profile` with +`mode: "report"` attributes the app's own intervals to those steps, so "does the +third selection cost more than the first" has an answer that does not depend on +the machine. + +- [ ] Selecting a conversation renders it **without a visible spinner** for a + conversation of a hundred events or so. **Eyes**. +- [ ] A conversation of a couple of thousand events opens without a stall. + **Eyes**. +- [ ] Selecting several conversations in a row stays responsive; the second and + third selections are not slower than the first. + **`debug_app_profile`**: drive five selections, then `mode: "report"`. The + `View bodies` and `FFI calls` columns should stay flat down the table. A + column that climbs is re-evaluation rather than loading, and the report + says so. +- [ ] Revisiting conversations does not cost memory a second time. + **`debug_app_profile`**: drive the same two conversations alternately six + times and read the `Footprint` column. It should plateau, because the climb + on first visit is the allocator's high-water mark rather than retention. + A column that keeps climbing while the same two conversations are + re-selected is a leak. +- [ ] **Switching conversations does not flash an empty pane**: the previous + transcript stays until the next one replaces it. **Eyes**. + +## Windows and tabs + +All **not yet mechanized**. `XCUIApplication.windows` counts and titles reach +most of this. + +- [ ] ⌘N opens a window on the workspace you were last reading. +- [ ] ⌘W closes the frontmost window or tab. +- [ ] Closing the last window and pressing ⌘N puts you back in the same + workspace. +- [ ] ⌘T opens a new tab. +- [ ] Window ▸ Merge All Windows collects windows into tabs. +- [ ] A tab can be dragged out into its own window. **Eyes**: a tear-off drag + has no accessibility action behind it. +- [ ] Opening the same workspace twice — once via ⌘O, once via Open Recent — + brings the existing window forward rather than opening a second one. + +## Open and Open Recent + +All **not yet mechanized**. The recents list is already isolated per test by +`JP_DEBUG_STATE_DIR`, so even Clear Menu is safe to drive. + +- [ ] ⌘O offers a directory chooser that only allows directories. +- [ ] Choosing a directory *inside* a workspace opens that workspace. +- [ ] Choosing a directory that is not in any workspace shows a readable message + rather than an empty list. +- [ ] The chosen workspace appears at the top of File ▸ Open Recent. +- [ ] A workspace whose directory has been deleted disappears from the menu on + the next launch. +- [ ] Open Recent ▸ Clear empties the menu and disables it. + +## State restoration + +Quit with ⌘Q and relaunch for each of these. + +All **not yet mechanized**. `XCUIApplication.terminate()` and `.launch()` are +the natural fit, but every UI test today launches with +`-ApplePersistenceIgnoreState` so it neither restores nor saves window state; +these need that turned off, and with it a way to keep a run out of the +developer's own saved state — the same problem the pasteboard has, and it may +well have the same answer. + +- [ ] The window reopens on the same workspace. +- [ ] The conversation that was selected is selected again. +- [ ] The transcript is scrolled roughly where it was left. +- [ ] The sidebar keeps the width it was dragged to. **Eyes**: see the Not + implemented section of `AFFORDANCES.md`. +- [ ] A conversation window opened on its own reopens showing its conversation. + +## Accessibility + +With VoiceOver on (⌘F5). All **eyes**: what VoiceOver announces is not what the +accessibility tree holds, and only a person hears the difference. + +- [ ] The sidebar announces itself as `Conversations`. +- [ ] Each row is read as one item, with its title and event count together. +- [ ] Messages in the transcript are read as text. + +Against the identifier table in `AFFORDANCES.md`: + +- [x] A row's identifier is the conversation ID, and does not change when the + conversation is retitled — every `ConversationListTests` case addresses + rows by ID, and `AccessibilityIDTests` pins the shape. +- [x] The transcript publishes one text area named `transcript.text`, whose value + is every message it is showing — `ConversationListTests/clickSelects` reads + that value and compares it whole. There is deliberately no element per + message; see the Accessibility section of `AFFORDANCES.md`. +- [x] `sidebar.filter` and `sidebar.filter.clear` are both reachable while the + filter is narrowing the list — `ConversationListTests/filtersAndClears`. +- [ ] Every other identifier in the table is reachable while its state is on + screen. **Not yet mechanized**: the three empty states have no test driving + them into view. + +## Known gaps + +Not defects; see the Not implemented section of `AFFORDANCES.md`. + +- Dragging into Finder produces nothing useful. +- Nothing accepts a dropped conversation. +- New turns written by a concurrent `jp query` do not appear until the workspace + is reopened. diff --git a/apps/macos/Sources/AccessibilityID.swift b/apps/macos/Sources/AccessibilityID.swift new file mode 100644 index 000000000..41052825e --- /dev/null +++ b/apps/macos/Sources/AccessibilityID.swift @@ -0,0 +1,79 @@ +/// Stable names for the elements an external accessibility driver has to find. +/// +/// Every identifier is derived from identity, never from display text: renaming +/// a conversation, rewording an empty state, or localizing the app leaves all of +/// them unchanged. None of these are read out to a person. +/// +/// The state identifiers are what lets a driver wait on a predicate instead of +/// sleeping — `sidebar.state.loading` disappearing and ``Sidebar/list`` +/// appearing is the load completing. +enum AccessibilityID { + /// The conversation list, and the two things that stand in for it. + enum Sidebar { + /// The conversation list. + /// + /// Exists only once the workspace has been read, so this is also the + /// "sidebar loaded" predicate. There is no separate + /// `sidebar.state.loaded`: the loaded sidebar is a single element, and a + /// view carries one identifier. + static let list = "sidebar.list" + + /// The box that narrows the list to matching conversations. + static let filter = "sidebar.filter" + + /// The button that empties the filter box. + /// + /// Always present, whether or not the box holds anything, so it is not a + /// predicate for the filter being in use. + static let filterClear = "sidebar.filter.clear" + + /// The spinner shown while the workspace is being read. + static let loadingState = "sidebar.state.loading" + + /// The message shown when a filter matches none of the conversations. + /// + /// Distinct from ``unavailableState``: the workspace was read and does + /// hold conversations, so this says the query is wrong rather than that + /// there is nothing to show. + static let noMatchesState = "sidebar.state.nomatches" + + /// The message shown when there is no list to show, and why. + static let unavailableState = "sidebar.state.unavailable" + + /// One row, named by the conversation it shows. + static func row(_ conversation: ConversationSummary.ID) -> String { + "sidebar.row.\(conversation)" + } + } + + /// The strip between the two panes that resizes the sidebar. + /// + /// Named because it is the one thing in the window a driver can only reach by + /// dragging: the sidebar's width is not settable through the accessibility + /// tree. Wider than the line it draws, so a pointer can hit it. + static let paneDivider = "window.divider" + + /// The transcript pane and its contents. + enum Transcript { + /// The scrolling transcript. + /// + /// Exists only once a conversation has been read, so this is also the + /// "transcript loaded" predicate, for the same reason as + /// ``Sidebar/list``. + static let scroll = "transcript.scroll" + + /// The spinner shown while a conversation is being read. + static let loadingState = "transcript.state.loading" + + /// The message shown when there is no transcript, covering both no + /// selection and a conversation that could not be read. + static let unavailableState = "transcript.state.unavailable" + + /// The text the transcript is drawn as. + /// + /// The whole conversation is one text view, so there is no element per + /// message to name. A driver addresses the transcript by this and reads + /// its value, which is every message it is showing. + static let text = "transcript.text" + } +} diff --git a/apps/macos/Sources/Bridging/JPFFI-Bridging-Header.h b/apps/macos/Sources/Bridging/JPFFI-Bridging-Header.h new file mode 100644 index 000000000..a5f64d6cc --- /dev/null +++ b/apps/macos/Sources/Bridging/JPFFI-Bridging-Header.h @@ -0,0 +1,6 @@ +// Exposes the `jp_ffi` C entry points to Swift. +// +// `jp_ffi.h` is generated by `just build-ffi` into the cargo target directory, +// which the target's HEADER_SEARCH_PATHS points at. + +#import "jp_ffi.h" diff --git a/apps/macos/Sources/ConversationDate.swift b/apps/macos/Sources/ConversationDate.swift new file mode 100644 index 000000000..054aea25b --- /dev/null +++ b/apps/macos/Sources/ConversationDate.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Turns the timestamps the library reports into the dates a row shows. +/// +/// Pure, and given its reference instant rather than reading a clock, so what a +/// row reads at any moment can be pinned without a running app. +enum ConversationDate { + /// The instant `text` names, or `nil` if it is not a timestamp the library + /// emits. + /// + /// Two shapes are accepted because the library emits both: it keeps whatever + /// sub-second precision a conversation was stored with, so a conversation JP + /// created from a wall clock carries a fractional-seconds part and one + /// written by hand usually does not. + static func parse(_ text: String) -> Date? { + if let date = try? whole.parse(text) { + return date + } + + return try? fractional.parse(text) + } + + /// How a row dates `conversation`, or `nil` if its timestamp will not parse. + /// + /// A row that cannot date itself shows no date rather than showing a + /// placeholder: the date is a convenience beside the title, and a row of + /// error text where a person expects "31 Jul" is worse than a gap. + static func activityLabel( + for conversation: ConversationSummary, + now: Date, + calendar: Calendar = .current, + locale: Locale = .current + ) -> String? { + guard let date = parse(conversation.lastActivatedAt) else { return nil } + + return label(for: date, now: now, calendar: calendar, locale: locale) + } + + /// How a row labels `date`. + /// + /// Three forms, by how far back it is: how long ago on the day it happened + /// ("21 minutes ago"), the day and month inside the same year ("31 Jul"), + /// and the year as well before that ("13 May 2024"). + /// + /// The order of day and month is the locale's, so a reader gets the one they + /// expect. + static func label( + for date: Date, + now: Date, + calendar: Calendar = .current, + locale: Locale = .current + ) -> String { + if calendar.isDate(date, inSameDayAs: now) { + return elapsed(from: date, to: now) + } + + let dayAndMonth = Date.FormatStyle( + locale: locale, + calendar: calendar, + timeZone: calendar.timeZone + ) + .day().month(.abbreviated) + + guard + calendar.component(.year, from: date) == calendar.component(.year, from: now) + else { + return date.formatted(dayAndMonth.year()) + } + + return date.formatted(dayAndMonth) + } + + /// How long before `now` the conversation was active, in the largest unit + /// that gives a whole number. + /// + /// Only ever called for two instants on the same day, so hours is the + /// coarsest unit it needs. Anything under a minute, and anything a clock + /// adjustment has put in the future, reads as just now. + private static func elapsed(from date: Date, to now: Date) -> String { + let seconds = Int(now.timeIntervalSince(date)) + guard seconds >= 60 else { return "just now" } + + let minutes = seconds / 60 + guard minutes >= 60 else { + return minutes == 1 ? "1 minute ago" : "\(minutes) minutes ago" + } + + let hours = minutes / 60 + return hours == 1 ? "1 hour ago" : "\(hours) hours ago" + } + + /// Parses `2024-09-02T12:30:00Z`. + /// + /// A format style rather than an `ISO8601DateFormatter`, because this is a + /// `Sendable` value and the formatter is a reference type that cannot be + /// held in a `static let` under strict concurrency checking. + private static let whole = Date.ISO8601FormatStyle(includingFractionalSeconds: false) + + /// Parses `2024-09-02T12:30:00.123456Z`. + private static let fractional = Date.ISO8601FormatStyle(includingFractionalSeconds: true) +} diff --git a/apps/macos/Sources/ConversationEvent.swift b/apps/macos/Sources/ConversationEvent.swift new file mode 100644 index 000000000..bb57bc951 --- /dev/null +++ b/apps/macos/Sources/ConversationEvent.swift @@ -0,0 +1,91 @@ +import Foundation + +/// One event in a conversation, as `jp_workspace_events` presents it. +/// +/// Hand-maintained to match `DisplayEvent` in the Rust `jp_ffi` crate. The `type` +/// tag names the *presentation*, not the stored event kind, so nothing here +/// decides what a `chat_request` means — that judgement is about the conversation +/// model and lives with the model. +/// +/// Only messages reach this side. Tool calls, reasoning, inquiries and config +/// changes have no prose to show and the library leaves them out. +enum ConversationEvent: Decodable, Sendable, Equatable { + /// A message the user sent, with the display name of whoever wrote it. + case userMessage(timestamp: String, author: String?, text: String) + + /// A message the assistant replied with. + case assistantMessage(timestamp: String, text: String) + + /// A presentation this build has no way to draw. + /// + /// Thrown rather than absorbed into a catch-all case, so the decision to + /// skip it belongs to whoever is decoding a whole turn rather than being + /// made silently here. A malformed event of a *known* presentation still + /// fails, which is what keeps a wire-format mistake visible. + struct UnknownPresentation: Error { + let type: String + } + + /// When the event was recorded, as RFC 3339 text. + /// + /// Kept unparsed because nothing displays it yet. Every timestamp the library + /// reports uses this one format, so one decoder will cover events and + /// conversation summaries alike when something needs it. + var timestamp: String { + switch self { + case .userMessage(let timestamp, _, _), + .assistantMessage(let timestamp, _): + timestamp + } + } + + /// What the event has to say. + var text: String { + switch self { + case .userMessage(_, _, let text), + .assistantMessage(_, let text): + text + } + } + + /// Who said it, as it should be shown above the message. + /// + /// A user message with no recorded author was written before a display name + /// was configured, and is still theirs. + var speaker: String { + switch self { + case .userMessage(_, let author, _): author ?? "You" + case .assistantMessage: "Assistant" + } + } + + private enum CodingKeys: String, CodingKey { + case type + case timestamp + case author + case text + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let timestamp = try container.decode(String.self, forKey: .timestamp) + + switch try container.decode(String.self, forKey: .type) { + case "user_message": + self = .userMessage( + timestamp: timestamp, + author: try container.decodeIfPresent(String.self, forKey: .author), + text: try container.decode(String.self, forKey: .text) + ) + + case "assistant_message": + self = .assistantMessage( + timestamp: timestamp, + text: try container.decode(String.self, forKey: .text) + ) + + case let type: + throw UnknownPresentation(type: type) + } + } +} diff --git a/apps/macos/Sources/ConversationFilter.swift b/apps/macos/Sources/ConversationFilter.swift new file mode 100644 index 000000000..63b4b8adc --- /dev/null +++ b/apps/macos/Sources/ConversationFilter.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Narrows the conversation list to what a person typed. +/// +/// Pure, so the matching rules can be pinned without a window: what counts as a +/// match is a product decision, and the place it is decided should not need a +/// running app to inspect. +enum ConversationFilter { + /// The conversations whose title contains `query`. + /// + /// A blank query matches everything, so clearing the box restores the list + /// rather than emptying it. + /// + /// Matching is on the title as the row displays it, including the placeholder + /// an untitled conversation shows: filtering a list means filtering what is on + /// screen, and a row a person can read but not search for is a surprise. + /// Conversation IDs are deliberately not searched — they are timestamps, and + /// matching them would let a query hit rows with no visible reason. + /// + /// Order is preserved, so the list stays most recently active first. + static func matches( + _ conversations: [ConversationSummary], query: String + ) + -> [ConversationSummary] + { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return conversations } + + return conversations.filter { displayTitle(of: $0).localizedStandardContains(query) } + } + + /// The title a row shows for a conversation. + /// + /// Untitled conversations are common — a title is generated after the first + /// turn — so the placeholder is part of what the list displays and part of + /// what a query searches. + static func displayTitle(of conversation: ConversationSummary) -> String { + return conversation.title ?? "Untitled" + } +} diff --git a/apps/macos/Sources/ConversationHistoryView.swift b/apps/macos/Sources/ConversationHistoryView.swift new file mode 100644 index 000000000..cd7763d4b --- /dev/null +++ b/apps/macos/Sources/ConversationHistoryView.swift @@ -0,0 +1,160 @@ +import SwiftUI + +/// What the history pane has to show. +/// +/// One value rather than separate properties, for the same reason as +/// ``WorkspaceState``: a load result reaches the view in a single mutation. +enum TranscriptState: Equatable, Sendable { + /// A conversation is being read. + case loading + + /// A conversation's turns, oldest first, ready to draw, and which + /// conversation they came from. + /// + /// The identifier travels with the turns rather than being read from the + /// view, because the two disagree for as long as a newly selected + /// conversation is still being read — the pane goes on showing the last one. + case loaded(id: String, turns: [ConversationTurn]) + + /// There is nothing to show, and why. + case unavailable(title: String, detail: String) + + /// Whether there is a transcript on screen worth keeping while another + /// loads. + var hasContent: Bool { + if case .loaded = self { true } else { false } + } +} + +/// The selected conversation, rendered as a scrolling transcript. +struct ConversationHistoryView: View { + let model: WorkspaceModel + let conversationID: ConversationSummary.ID? + + @State private var state: TranscriptState = .unavailable( + title: "No Conversation Selected", + detail: "Pick a conversation to read it." + ) + + var body: some View { + Trace.measuring("ConversationHistoryView.body", target: Self.traceTarget) { + content + } + } + + /// What the pane shows, timed by ``body``. + private var content: some View { + Group { + switch state { + case .loading: + ProgressView() + .controlSize(.small) + .accessibilityLabel("Loading conversation") + .accessibilityIdentifier(AccessibilityID.Transcript.loadingState) + + case .loaded(let id, let turns): + TranscriptTextView(conversationID: id, turns: turns) + // One text view per conversation, so switching builds a new + // one rather than moving a new transcript into the old one. + // Sharing it kept the scroll offset across a switch, because + // nothing told it the content underneath had been replaced. + // + // Keyed on the conversation *on screen*, not the one + // selected. Keyed on the selection it changed the moment a + // row was clicked, which built a second text view around the + // outgoing transcript and paid for the whole document again + // before the new one had even been read. + .id(id) + + case .unavailable(let title, let detail): + ContentUnavailableView( + title, + systemImage: "bubble.left.and.text.bubble.right", + description: Text(detail) + ) + .accessibilityIdentifier(AccessibilityID.Transcript.unavailableState) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Theme.editorBackground.color) + // Keyed on the *open* workspace, not the requested one. A window opened + // straight onto a conversation renders while its workspace is still + // opening, and keying on the request would fire that first read against + // no session and never try again. + .task(id: ReadKey(workspace: model.openWorkspace, conversation: conversationID)) { + await load() + } + } + + /// What this pane's events are attributed to. + private static let traceTarget = "JP.Transcript" + + /// The interval covering one conversation being picked and read. + /// + /// Named once because the read nested inside it reports it as its enclosing + /// span, and two spellings would break that link. + /// + /// Covers the read alone. Building the text and laying it out happen later, + /// while the view draws, and are timed there as `transcript.render`. + private static let selectionSpan = "conversation.select" + + /// What a reload depends on. + private struct ReadKey: Equatable { + let workspace: String? + let conversation: String? + } + + private func load() async { + guard let conversationID else { + state = .unavailable( + title: "No Conversation Selected", + detail: "Pick a conversation to read it." + ) + return + } + + // Nothing to read until the workspace is open; the task runs again when + // it is. + guard model.openWorkspace != nil else { return } + + // The transcript already on screen stays there until the next one is + // ready. Clearing first put an empty pane between the two, which reads as + // a flash when the read takes a few milliseconds. Only an empty pane gets + // a spinner, because there is nothing to keep. + if !state.hasContent { + state = .loading + } + + let timing = Trace.interval(Self.selectionSpan, target: Self.traceTarget) + let result = await model.events(for: conversationID, spans: [Self.selectionSpan]) + + // Selecting another conversation cancels this task, but the read it + // started still finishes, and its result must not replace the new one. + guard !Task.isCancelled else { + timing.end([("cancelled", true)]) + return + } + + let next: TranscriptState = + switch result { + case .success(let turns) where turns.isEmpty: + .unavailable( + title: "Empty Conversation", + detail: "This conversation has no messages yet." + ) + case .success(let turns): + .loaded(id: conversationID, turns: turns) + case .failure(let error): + .unavailable(title: "Could Not Read Conversation", detail: error.message) + } + + // Animated at the assignment rather than through `.animation(value:)`, + // which would compare the whole transcript — every message — on each + // change to decide whether to animate. + withAnimation(DebugState.animated(.easeInOut(duration: 0.12))) { + state = next + } + + timing.end() + } +} diff --git a/apps/macos/Sources/ConversationList.swift b/apps/macos/Sources/ConversationList.swift new file mode 100644 index 000000000..49419b8b8 --- /dev/null +++ b/apps/macos/Sources/ConversationList.swift @@ -0,0 +1,132 @@ +import SwiftUI + +/// The conversation list, as a view of its own so that resizing the sidebar does +/// not re-render it. +/// +/// A `List` of a thousand rows is expensive to evaluate, and dragging the divider +/// changes the sidebar's width on every frame of the drag. Built inline in the +/// window's body, the whole list was rebuilt each of those frames and the drag +/// felt heavy. As a separate view compared by its data, SwiftUI finds its inputs +/// unchanged and skips it: the width applies to the frame around it, which costs +/// nothing. +/// +/// Equality is by data alone, ignoring the closures and the binding. Those never +/// compare equal, and a value carrying them would differ on every comparison — +/// which is the thing this exists to prevent. ``WorkspaceActions`` makes the same +/// trade for the same reason. +struct ConversationList: View, Equatable { + /// The conversations to show, in the order they appear. + let matches: [ConversationSummary] + + /// The rows that draw no line under them. + let separatorless: Set + + /// The instant the rows date their conversations against. + /// + /// Passed in rather than read here, because a fresh `Date()` per render would + /// make every comparison unequal and defeat the skipping this view is for. + let now: Date + + /// Which conversation is selected. + /// + /// The same value the binding below carries, held separately because equality + /// has to see it: a `Binding` is read through a closure, which a `nonisolated` + /// comparison cannot do, and a comparison that ignored the selection would + /// leave the highlight on the row it was last drawn on. + let selectedID: ConversationSummary.ID? + + /// The selected conversation, for the list to write as it is clicked through. + @Binding var selection: ConversationSummary.ID? + + /// The `jp://` reference for a conversation, for dragging it out. + let reference: (ConversationSummary) -> ConversationRef + + /// Open each named conversation in a window of its own. + let openWindows: (Set) -> Void + + /// Put each named conversation's URI on the pasteboard. + let copyLinks: (Set) -> Void + + /// Called when the list leaves the top of its content, or returns to it. + let scrolledAwayFromTop: (Bool) -> Void + + /// `nonisolated` because `Equatable` is: a `View` is main-actor isolated and its + /// members inherit that, which a protocol requirement declared without + /// isolation cannot satisfy. Safe, because everything compared is plain data. + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.matches == rhs.matches + && lhs.separatorless == rhs.separatorless + && lhs.now == rhs.now + && lhs.selectedID == rhs.selectedID + } + + var body: some View { + List(matches, selection: $selection) { conversation in + ConversationRow( + conversation: conversation, + isSelected: selectedID == conversation.id, + drawsSeparator: !separatorless.contains(conversation.id), + now: now + ) + .draggable(reference(conversation)) + // Silences the table view's own selection fill, which is the system + // accent colour. Placed in the row because that is where it can reach + // the table view; see ``ListSelectionHighlight``. + .background(ListSelectionHighlight.removed) + // The row fills its cell edge to edge and draws its own background, + // selection and separator. Everything the list would otherwise + // contribute is turned off here: its separators run to both edges, and + // its selection is the system accent colour. + // + // Asking for no insets does not get none. A plain list keeps eight + // points at the leading edge whatever this says, which is the gap a + // reader sees beside the selection. + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + .listStyle(.plain) + // Mapped to a `Bool` rather than watched as an offset: the action then runs + // when the answer changes rather than on every frame of a scroll. + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y > 0 + } action: { _, scrolled in + scrolledAwayFromTop(scrolled) + } + // Uncovers the `.background` below. Without it the list draws the system's + // own list background over the sidebar's colour. + .scrollContentBackground(.hidden) + .background(Theme.sidebarBackground.color) + .accessibilityLabel("Conversations") + .accessibilityIdentifier(AccessibilityID.Sidebar.list) + // Escape clears the selection and empties the detail pane. Reaching a + // workspace with nothing selected is otherwise only possible by opening + // one, which makes "no conversation chosen" a state the app can enter and + // never return to. + // + // On the list rather than on the window, so Escape in the filter field + // still means "clear what I typed". + .onExitCommand { selection = nil } + // One menu for the list rather than one per row. A per-row `contextMenu` is + // built for every row the list realizes, which a sidebar of a thousand + // conversations pays for on every scroll. + // + // `primaryAction` is also how a double-click is meant to be handled here: a + // tap gesture on a row competes with the click the list uses to move the + // selection. + // + // Both act on the full list, not the visible one: an identifier that came + // from a row is valid whether or not the filter still shows it. + .contextMenu(forSelectionType: ConversationSummary.ID.self) { ids in + // These carry no accessibility identifier because they cannot. SwiftUI + // bridges a menu button to an `NSMenuItem` and does not carry the + // modifier across, on the button or on its label, so both items report + // the selector name `menuAction:`. A driver addresses them by title. + Button("Open in New Window") { openWindows(ids) } + Divider() + Button("Copy Link") { copyLinks(ids) } + } primaryAction: { ids in + openWindows(ids) + } + } +} diff --git a/apps/macos/Sources/ConversationOrder.swift b/apps/macos/Sources/ConversationOrder.swift new file mode 100644 index 000000000..bf9814ff3 --- /dev/null +++ b/apps/macos/Sources/ConversationOrder.swift @@ -0,0 +1,42 @@ +/// Puts the conversation list in the order the sidebar shows it. +/// +/// Pure, and separate from the library's own ordering on purpose: the library +/// reports conversations most recently active first, which is a fact about the +/// data, and where a pinned conversation belongs in a list is a decision about +/// the interface. +enum ConversationOrder { + /// `conversations` with the pinned ones first. + /// + /// A stable partition: inside each group the given order is kept, so pinning + /// a conversation lifts it to the top and moves nothing else. Pinned + /// conversations stay most recently active first among themselves. + static func pinnedFirst(_ conversations: [ConversationSummary]) -> [ConversationSummary] { + let pinned = conversations.filter(\.isPinned) + + // The common case, and worth the check: this runs on every keystroke in + // the filter box, over every conversation the workspace holds. + guard !pinned.isEmpty else { return conversations } + + return pinned + conversations.filter { !$0.isPinned } + } + + /// The rows that draw no line under them, given what is selected. + /// + /// The selected row and the one above it, so the selection's rounded fill is + /// not cut across by a separator at either end of it. Empty when nothing is + /// selected, and when the selection is not in the list — which happens while a + /// filter is hiding the selected conversation. + static func rowsWithoutSeparator( + in conversations: [ConversationSummary], + selecting selection: ConversationSummary.ID? + ) -> Set { + guard + let selection, + let index = conversations.firstIndex(where: { $0.id == selection }) + else { return [] } + + guard index > conversations.startIndex else { return [selection] } + + return [selection, conversations[index - 1].id] + } +} diff --git a/apps/macos/Sources/ConversationRef.swift b/apps/macos/Sources/ConversationRef.swift new file mode 100644 index 000000000..d7b087f84 --- /dev/null +++ b/apps/macos/Sources/ConversationRef.swift @@ -0,0 +1,40 @@ +import CoreTransferable +import Foundation + +/// A conversation, identified well enough to reopen from anywhere. +/// +/// Carries the workspace path as well as the ID so a value copied, dragged, or +/// restored into a new window can be read without a window already having that +/// workspace open. +struct ConversationRef: Codable, Hashable, Sendable { + let workspacePath: String + let conversationID: String + + /// The title to show for the conversation, when one is known. + /// + /// Cosmetic, and absent on a value restored from disk, so nothing depends on + /// it being present. + var title: String? + + /// A window title that says something even when the title is unknown. + var displayTitle: String { + title ?? "Conversation \(conversationID)" + } +} + +extension ConversationRef: Transferable { + /// How the conversation crosses a drag or a copy. + /// + /// Text, deliberately: a `jp://` URI is the form JP itself uses to reference + /// a conversation, so a paste into a terminal, an editor, or a query is + /// useful rather than opaque. A private binary type would only be readable by + /// this app, which has nowhere to drop one yet. + static var transferRepresentation: some TransferRepresentation { + ProxyRepresentation(exporting: \.uri) + } + + /// The conversation as a `jp://` URI. + var uri: String { + "jp://\(conversationID)" + } +} diff --git a/apps/macos/Sources/ConversationRow.swift b/apps/macos/Sources/ConversationRow.swift new file mode 100644 index 000000000..5c88cd32f --- /dev/null +++ b/apps/macos/Sources/ConversationRow.swift @@ -0,0 +1,175 @@ +import SwiftUI + +/// One conversation in the sidebar. +struct ConversationRow: View { + /// The height every row is laid out at. + /// + /// Fixed, not measured. A list has to know its total content height to size + /// its scroll bar, and with variable-height rows that means measuring every + /// row rather than the visible ones — a cost that grows with the number of + /// conversations. A uniform height lets it multiply instead. + /// + /// Sized for two lines of title over one of metadata, which is the tallest a + /// row gets. A title of one line leaves the rest of the space empty rather + /// than closing the gap, so the metadata sits on the same baseline in every + /// row. A larger system text size would clip it; a row that grows with the + /// text needs the list to supply the height some other way. + static let height: CGFloat = 72 + + /// How far the text sits in from the row's own leading edge. + private static let textInset: CGFloat = 16 + + /// How wide the bar marking the selected row is. + private static let accentBarWidth: CGFloat = 5 + + private static let selectionRadius: CGFloat = 6 + + let conversation: ConversationSummary + + /// Whether this is the selected conversation. + /// + /// The row draws its own selection rather than letting the list draw one; see + /// ``ListSelectionHighlight`` for why it has to. + let isSelected: Bool + + /// Whether to draw the line under the row. + /// + /// False for the selected row and the one above it, so no separator cuts + /// across either end of the selection's rounded fill. + let drawsSeparator: Bool + + /// The instant the row dates the conversation against. + /// + /// Passed in rather than read here, so one clock read covers a whole render + /// of the list instead of one per realized row. + let now: Date + + var body: some View { + ZStack { + Theme.sidebarBackground.color + + if isSelected { + selection + } + + text + } + .frame(height: Self.height) + .overlay(alignment: .bottom) { + if drawsSeparator { + separator + } + } + // Labelled explicitly, and children ignored rather than combined: + // combining walks and merges each row's accessibility subtree, which a + // sidebar of a thousand rows pays for as it scrolls. + .accessibilityElement(children: .ignore) + .accessibilityLabel(label) + // Safe on the same view as the `.ignore` above: that collapses the row + // to one leaf element, and this names it. A row is addressed by the + // conversation's ID, so retitling one does not move it. + .accessibilityIdentifier(AccessibilityID.Sidebar.row(conversation.id)) + } + + /// The title, and the metadata under it. + private var text: some View { + // No spacing and no spacer between the two. A `Spacer` here is charged the + // stack's spacing twice, once on each side of it, and those eight points + // are the difference between a row that fits two lines of title and one + // that fits one: the title then takes a single line and truncates however + // high its line limit is. The title claims the leftover height instead, + // which holds the metadata to the bottom just as well. + VStack(alignment: .leading, spacing: 0) { + Text(verbatim: title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.bodyText.color) + .lineLimit(2) + .frame(maxHeight: .infinity, alignment: .topLeading) + + metadata + } + // Inside the padding, not around it. Outside, the stack keeps its ideal + // width and the title is offered as much room as it asks for: it then + // never wraps, and the row clips it into an ellipsis instead. Inside, the + // stack is handed the row's width and a long title wraps to its second + // line as intended. + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.horizontal, Self.textInset) + .padding(.vertical, 10) + } + + /// The pin, the date and the event count, under the title. + private var metadata: some View { + HStack(spacing: 5) { + if conversation.isPinned { + // Rotated because SF Symbols draws a pin upright and this one + // reads as pinning something to a board. + Image(systemName: "pin.fill") + .rotationEffect(.degrees(45)) + .foregroundStyle(Theme.accent.color) + } + + if let date = ConversationDate.activityLabel(for: conversation, now: now) { + Text(verbatim: date) + Text(verbatim: "·") + } + + Text(verbatim: eventCount) + } + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText.color) + } + + /// The line under the row. + /// + /// Drawn by the row rather than by the list, for two reasons: + /// `listRowSeparatorTint` leaves a plain list's separators the system colour + /// on macOS, and the list draws them edge to edge. + private var separator: some View { + Rectangle() + .fill(Theme.rowSeparator.color) + .frame(height: 1) + } + + /// What fills the selected row. + /// + /// The accent bar belongs to the fill rather than to the row, and is clipped + /// to the same rounded rectangle: against the row's edge it would run the + /// window's full height and square off the corners the fill has. + private var selection: some View { + RoundedRectangle(cornerRadius: Self.selectionRadius) + .fill(Theme.selectedRowBackground.color) + .overlay(alignment: .leading) { + Rectangle() + .fill(Theme.accent.color) + .frame(width: Self.accentBarWidth) + } + .clipShape(RoundedRectangle(cornerRadius: Self.selectionRadius)) + } + + /// What a screen reader announces for the row. + /// + /// The date is deliberately left out. It is relative for anything active + /// today, so a label carrying it would say something different one minute + /// later and could not be pinned by a test. + private var label: String { + let pinned = conversation.isPinned ? ", pinned" : "" + return "\(title), \(eventCount)\(pinned)" + } + + /// Shared with the filter, so a row can always be found by the words it + /// shows. Two placeholders that drifted apart would make untitled + /// conversations visible but unsearchable. + private var title: String { + ConversationFilter.displayTitle(of: conversation) + } + + /// Pluralized by hand, and `verbatim` so neither this nor the title goes + /// through a localization lookup. + /// + /// `^[\(count) event](inflect: true)` reads better but resolves grammatical + /// agreement at runtime, once per row, every time the list realizes one. + private var eventCount: String { + conversation.eventsCount == 1 ? "1 event" : "\(conversation.eventsCount) events" + } +} diff --git a/apps/macos/Sources/ConversationTurn.swift b/apps/macos/Sources/ConversationTurn.swift new file mode 100644 index 000000000..8a6f4fe4b --- /dev/null +++ b/apps/macos/Sources/ConversationTurn.swift @@ -0,0 +1,61 @@ +import Foundation + +/// One turn of a conversation, as `jp_workspace_events` presents it. +/// +/// Hand-maintained to match `DisplayTurn` in the Rust `jp_ffi` crate. A turn is +/// one user request through the assistant's final answer to it, and where its +/// boundaries fall is decided on the library side: the rules involve an +/// implicit leading turn and a marker that opens a turn only sometimes, neither +/// of which is recoverable from the events alone. +/// +/// A turn the library had nothing to show for is absent rather than empty, so a +/// separator can be drawn between every pair of turns received. +struct ConversationTurn: Decodable, Sendable, Equatable, Identifiable { + /// Where the turn sits in the conversation, counting from zero. + /// + /// The position among *all* turns, so the numbering skips any the library + /// had nothing to show for. Two consecutive turns here can therefore be + /// numbered 4 and 7. + let index: Int + + /// What the turn has to show, oldest first. + let events: [ConversationEvent] + + var id: Int { index } + + private enum CodingKeys: String, CodingKey { + case index + case events + } + + init(index: Int, events: [ConversationEvent]) { + self.index = index + self.events = events + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + index = try container.decode(Int.self, forKey: .index) + events = try container.decode([SkippableEvent].self, forKey: .events) + .compactMap(\.event) + } +} + +/// An event that decodes to nothing when this build cannot draw it. +/// +/// A later library adding a presentation — a tool call, an attachment — would +/// otherwise fail the whole conversation on an app that predates it. Only an +/// unrecognized `type` is skipped; a known presentation missing its fields +/// still throws. +private struct SkippableEvent: Decodable { + let event: ConversationEvent? + + init(from decoder: any Decoder) throws { + do { + event = try ConversationEvent(from: decoder) + } catch is ConversationEvent.UnknownPresentation { + event = nil + } + } +} diff --git a/apps/macos/Sources/ConversationWindow.swift b/apps/macos/Sources/ConversationWindow.swift new file mode 100644 index 000000000..6c12965a2 --- /dev/null +++ b/apps/macos/Sources/ConversationWindow.swift @@ -0,0 +1,41 @@ +import SwiftUI + +/// One conversation, in a window of its own. +/// +/// Opened by double-clicking a conversation, and restored at launch from the +/// reference the system kept, which is why a reference carries its workspace +/// path: there may be no workspace window open to ask. +struct ConversationWindow: View { + /// The scene identifier `openWindow` addresses this group by. + static let sceneID = "conversation" + + let reference: ConversationRef? + + @State private var model = WorkspaceModel() + + var body: some View { + Group { + if let reference { + ConversationHistoryView(model: model, conversationID: reference.conversationID) + .navigationTitle(reference.displayTitle) + // Reachable from whichever Space is on screen, for a driven + // build. See ``DebugSpaces``. + .background(DebugSpaces.joinEverySpace()) + } else { + ContentUnavailableView( + "No Conversation", + systemImage: "bubble.left.and.text.bubble.right", + description: Text("This window has nothing to show.") + ) + } + } + .task(id: reference) { await load() } + } + + /// Open the reference's workspace, which this window does not share with the + /// one the conversation came from. + private func load() async { + guard let reference, !reference.workspacePath.isEmpty else { return } + await model.open(reference.workspacePath) + } +} diff --git a/apps/macos/Sources/DebugSpaces.swift b/apps/macos/Sources/DebugSpaces.swift new file mode 100644 index 000000000..97d42f120 --- /dev/null +++ b/apps/macos/Sources/DebugSpaces.swift @@ -0,0 +1,57 @@ +import AppKit +import SwiftUI + +/// Keeps a driven window reachable whichever Space is on screen. +/// +/// macOS remembers which Space an application's windows belong to, keyed by +/// bundle identifier. Each debug slot runs its own copy of the app under its own +/// identifier — which is what isolates window state and the recents list — so a +/// slot's copy can acquire a Space assignment of its own and go on reopening +/// there. The assignment lives in the window server, not in the slot's state +/// directory, so nothing the harness controls can clear it. +/// +/// A window on a Space that is not showing is not merely out of reach of a +/// synthesized click: it is absent from the accessibility tree entirely. Every +/// step a driver takes fails, and it fails as `identifier_not_found` — which +/// reads like a view that was never built rather than a window sitting one Space +/// away. +/// +/// `canJoinAllSpaces` makes the window present wherever the person looking at it +/// happens to be, so the tree finds it and its frame means what the screen shows. +/// Activating the app would also work and would steal focus on every launch, +/// which a harness that deliberately launches in the background must not do. +/// +/// Add it as a background of a window's content: +/// +/// ```swift +/// content.background(DebugSpaces.joinEverySpace()) +/// ``` +enum DebugSpaces { + /// A view that puts its window on every Space, for a driven build. + /// + /// Draws nothing, and does nothing at all unless the app was launched with a + /// debug state directory. A window that followed the Space in an app somebody + /// installed would be a window that will not stay where it was put. + static func joinEverySpace() -> some View { + Joiner() + } + + private struct Joiner: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + Probe() + } + + func updateNSView(_ view: NSView, context: Context) {} + } + + /// A view that does nothing but widen its window's Space membership. + private final class Probe: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + + guard DebugState.directory != nil, let window else { return } + + window.collectionBehavior.insert(.canJoinAllSpaces) + } + } +} diff --git a/apps/macos/Sources/DebugState.swift b/apps/macos/Sources/DebugState.swift new file mode 100644 index 000000000..162f07552 --- /dev/null +++ b/apps/macos/Sources/DebugState.swift @@ -0,0 +1,158 @@ +import AppKit +import Foundation +import SwiftUI + +/// The scratch directory a harness driving the app points it at. +/// +/// With `JP_DEBUG_STATE_DIR` set, the app keeps the state it would otherwise share +/// with the rest of the system inside that directory, and records its process id +/// there. Unset, nothing under it is touched and the app behaves as it ships. +/// +/// This exists because the alternatives do not work. The recent-workspace list is +/// keyed by bundle identifier and written on the app's behalf by a system daemon, +/// so it follows neither `HOME` nor anything else in the app's environment; and the +/// file holding it needs Full Disk Access to read, so a harness cannot inspect or +/// restore it either. +/// +/// Window state saved by `@SceneStorage` is **not** covered by this directory. It +/// is keyed by bundle identifier, so isolating it is the launching harness's job +/// rather than something this variable can reach. +enum DebugState { + /// The environment variable naming the directory. + static let variable = "JP_DEBUG_STATE_DIR" + + /// The environment variable naming a pasteboard to copy to. + static let pasteboardVariable = "JP_DEBUG_PASTEBOARD" + + /// The pasteboard the app copies to. + /// + /// The system one, unless a debug build was told otherwise. There is a + /// single system pasteboard and it holds whatever the person at the + /// keyboard last copied, so a driven run that copied into it would destroy + /// their clipboard. Saving and restoring around the run is not a way out: + /// a pasteboard item can be a promise its owner fulfils lazily, so what + /// goes back is a degraded copy of what they had. + /// + /// A named pasteboard is a real one that simply nobody is looking at, so a + /// test can read back exactly what the app wrote. + /// + /// Compiled out of a release build. An app that could be told at launch to + /// copy somewhere nothing pastes from is a bug report waiting to happen, + /// and that risk is not worth carrying to ship a test seam. + static var pasteboard: NSPasteboard { + #if DEBUG + if let name = ProcessInfo.processInfo.environment[pasteboardVariable], + !name.isEmpty + { + return NSPasteboard(name: NSPasteboard.Name(name)) + } + #endif + + return .general + } + + /// The environment variable that turns the app's animations off. + static let animationVariable = "JP_DEBUG_DISABLE_ANIMATIONS" + + /// Whether the app should animate at all. + /// + /// A UI test driving the app waits for it to stop moving before each + /// action, so every animation is time added to every test that triggers + /// one. Turning them off is worth more than shortening them, and costs a + /// test nothing it was checking: what an animation looks like is a question + /// for a person, and `QA.md` keeps it. + /// + /// Compiled out of a release build, like ``pasteboard``, so an app someone + /// installs cannot be talked into feeling broken. + static var animationsDisabled: Bool { + #if DEBUG + guard let value = ProcessInfo.processInfo.environment[animationVariable] else { + return false + } + + return !value.isEmpty + #else + return false + #endif + } + + /// `animation` normally, and nothing when animations are off. + /// + /// Every animation in the app goes through this, so turning them off stays + /// one decision rather than one per call site. + static func animated(_ animation: Animation) -> Animation? { + animationsDisabled ? nil : animation + } + + /// The directory, or `nil` when the variable is unset or empty. + static var directory: URL? { + guard let value = ProcessInfo.processInfo.environment[variable], !value.isEmpty else { + return nil + } + + return URL(fileURLWithPath: value) + } + + /// The recents store the app runs with. + @MainActor + static func defaultStore() -> any RecentsStore { + guard let directory else { + return DocumentControllerRecents() + } + + return FileRecents(path: directory.appendingPathComponent("recents.json")) + } + + /// Record this process's id at `/pid`. + /// + /// A harness launching the app through `open(1)` gets no process id back, and + /// matching on the executable path cannot tell a driven instance from one the + /// developer left running. A pid the app reports itself is unambiguous. + static func recordProcessID() { + guard let directory else { + return + } + + let file = directory.appendingPathComponent("pid") + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + try "\(getpid())\n".write(to: file, atomically: true, encoding: .utf8) + } catch { + let path = file.path(percentEncoded: false) + FileHandle.standardError.write( + Data("debug state: could not write \(path): \(error)\n".utf8) + ) + } + + recordImageSlide() + } + + /// Record how far ASLR shifted this process's main image. + /// + /// A profiler resolves a sampled address by subtracting this from it, and the + /// alternative is recovering it from the kernel's image-load events — which + /// only exist in a trace that was already recording when dyld mapped the + /// image. A recorder that attached to an already-running app has none of + /// them, so without this every frame it samples stays a bare address. + /// + /// Index 0 is the main executable. + private static func recordImageSlide() { + guard let directory else { + return + } + + let file = directory.appendingPathComponent("slide") + let slide = _dyld_get_image_vmaddr_slide(0) + do { + try "\(slide)\n".write(to: file, atomically: true, encoding: .utf8) + } catch { + let path = file.path(percentEncoded: false) + FileHandle.standardError.write( + Data("debug state: could not write \(path): \(error)\n".utf8) + ) + } + } +} diff --git a/apps/macos/Sources/JPApp.swift b/apps/macos/Sources/JPApp.swift new file mode 100644 index 000000000..dbbdffd56 --- /dev/null +++ b/apps/macos/Sources/JPApp.swift @@ -0,0 +1,171 @@ +import SwiftUI + +/// A reader for JP conversations. +/// +/// A plain `WindowGroup`, deliberately: keying the group by workspace path made +/// each window's identity its workspace, which meant ⌘N on a workspace already on +/// screen brought that window forward instead of opening one, and ⌘T had nothing +/// to duplicate. Each window now decides which workspace it shows, and holds that +/// choice in its own scene storage. +@main +struct JPApp: App { + @State private var recents = RecentWorkspaces() + + init() { + // Earliest point the app can report which process it is, for a harness + // that launched it through `open(1)` and got no pid back. + DebugState.recordProcessID() + + // Also the earliest point it can time itself from, which is what makes + // "launch to first window" a number rather than an impression. + Trace.beginLaunch() + } + + /// What the front window offers the File menu. + /// + /// A menu command acts on the focused window, and only that window knows + /// which workspace it is showing. + /// + /// Whatever is published here must compare equal to itself between renders. + /// See ``WorkspaceActions`` for what happens when it does not. + @FocusedValue(\.workspaceActions) private var actions + + @Environment(\.openWindow) private var openWindow + + /// The scene identifier `openWindow` addresses workspace windows by. + private static let workspaceSceneID = "workspace" + + var body: some Scene { + WindowGroup(id: Self.workspaceSceneID) { + WorkspaceWindow() + .environment(recents) + } + // No title bar, so no strip of chrome above the transcript and no title + // text repeating what the sidebar already says. The window buttons stay, + // over the top-left of the sidebar, and the window still drags by that + // strip. + .windowStyle(.hiddenTitleBar) + .commands { workspaceCommands } + + // A conversation pulled out of a workspace window, into its own. + WindowGroup(id: ConversationWindow.sceneID, for: ConversationRef.self) { $reference in + ConversationWindow(reference: reference) + } + } + + @CommandsBuilder + private var workspaceCommands: some Commands { + // Show/Hide Sidebar, in the View menu where AppKit puts it. Ours rather + // than `SidebarCommands()`, which acts on a `NavigationSplitView`'s column + // visibility and the window holds its two panes itself. + // + // A hidden sidebar takes the conversation list and the filter box with it, + // and there is no button for it, so this item and its keystroke are the + // only way back. + CommandGroup(after: .sidebar) { + Button(actions?.isSidebarVisible == false ? "Show Sidebar" : "Hide Sidebar") { + actions?.toggleSidebar() + } + .keyboardShortcut("s", modifiers: [.control, .command]) + .disabled(actions == nil) + } + + // Replaces "New", which a reader has no use for, but keeps "New Window": + // macOS hangs window tabbing off it, and without it there is nothing for + // ⌘T to duplicate. + CommandGroup(replacing: .newItem) { + Button("New Window") { openWindow(id: Self.workspaceSceneID) } + .keyboardShortcut("n", modifiers: .command) + + Divider() + + Button("Open Workspace…") { actions?.choose() } + .keyboardShortcut("o", modifiers: .command) + .disabled(actions == nil) + + Menu("Open Recent") { + ForEach(recents.urls, id: \.self) { url in + Button(url.lastPathComponent) { actions?.open(url) } + } + + if !recents.urls.isEmpty { + Divider() + Button("Clear Menu") { recents.clear() } + } + } + .disabled(recents.urls.isEmpty || actions == nil) + } + + // Copying a conversation's URI is the only thing the reader does to a + // conversation besides opening it, and the list's context menu is a + // pointing device away. In the Edit menu it also has a keystroke, and it + // is reachable by anything driving the app through the menu bar. + CommandGroup(after: .pasteboard) { + Button("Copy Link") { actions?.copyLinks() } + .keyboardShortcut("c", modifiers: [.command, .shift]) + .disabled(actions?.hasSelection != true) + } + } +} + +/// What the focused workspace window lets the File menu do to it. +/// +/// Equatable by window, not by content. A focused value is republished every time +/// the view publishing it renders, and the App observing it is invalidated +/// whenever the value differs. Closures never compare equal, so a value carrying +/// them and nothing else differs every single time: the window renders, the App is +/// invalidated, the scene is re-evaluated, the window renders again. +/// +/// That loop does not merely rebuild the menu bar — which discards the items +/// AppKit injects into View and Window, since `SwiftUI` reconstructs those menus +/// from its own commands and knows nothing of them. It re-renders the entire scene +/// continuously, and the whole app is sluggish for it: lists stutter as they +/// scroll, and the sidebar snaps rather than animating. +/// +/// Comparing the window's identity instead makes a republished value from the same +/// window look unchanged, which ends the loop. +struct WorkspaceActions: Equatable { + /// Identifies the window these act on, stable for that window's lifetime. + let windowID: UUID + + /// Whether the window has a conversation selected. + /// + /// Part of the equality along with the window, so a menu item conditioned on + /// it is re-evaluated when the selection appears or goes away, and at no + /// other time. + let hasSelection: Bool + + /// Whether the window's sidebar is showing. + /// + /// Part of the equality too, because the View menu's item is titled from it. + let isSidebarVisible: Bool + + /// Put the directory chooser on screen. + let choose: () -> Void + + /// Show a workspace in this window. + let open: (URL) -> Void + + /// Put the selected conversation's URI on the pasteboard. + let copyLinks: () -> Void + + /// Show the sidebar if it is hidden, hide it if it is showing. + let toggleSidebar: () -> Void + + static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.windowID == rhs.windowID + && lhs.hasSelection == rhs.hasSelection + && lhs.isSidebarVisible == rhs.isSidebarVisible + } +} + +struct WorkspaceActionsKey: FocusedValueKey { + typealias Value = WorkspaceActions +} + +extension FocusedValues { + var workspaceActions: WorkspaceActions? { + get { self[WorkspaceActionsKey.self] } + set { self[WorkspaceActionsKey.self] = newValue } + } +} diff --git a/apps/macos/Sources/ListSelectionHighlight.swift b/apps/macos/Sources/ListSelectionHighlight.swift new file mode 100644 index 000000000..27bc2cbe6 --- /dev/null +++ b/apps/macos/Sources/ListSelectionHighlight.swift @@ -0,0 +1,79 @@ +import AppKit +import SwiftUI + +/// Stops the table view under a SwiftUI `List` from drawing its own selection. +/// +/// Only the drawing is suppressed. The selection is still the list's, so click +/// selection, the arrow keys and `contextMenu(forSelectionType:)` all keep +/// working, and the row draws the selection the design calls for. +/// +/// This reaches for AppKit because SwiftUI offers no way to say it. A `List` on +/// macOS is an `NSTableView`, and a selected row is filled with the system accent +/// colour by the row view itself, underneath whatever the row draws. Neither +/// `listRowBackground` nor an opaque fill in the row's own content hides it. +/// +/// Put it in a *row*, not behind the list: +/// +/// ```swift +/// List(...) { item in +/// ItemRow(item) +/// .background(ListSelectionHighlight.removed) +/// } +/// ``` +/// +/// A row's backing view is a descendant of the table view, so it can walk up to +/// the table in two hops. A view placed behind the whole list cannot: it is built +/// before the table exists, and finds nothing to configure. +enum ListSelectionHighlight { + /// A view that turns the highlight off for the table holding it. + /// + /// Draws nothing, and fills whatever it is given rather than being sized to + /// nothing: SwiftUI builds no backing view for a subview with no area, and one + /// that is never built never runs. + static var removed: some View { + Remover() + } + + private struct Remover: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + Probe() + } + + /// Applied again on every update, which is what makes this hold: rows are + /// realized and recycled as the list scrolls, and a table view SwiftUI + /// rebuilt is back to drawing its own selection until the next row asks it + /// not to. + func updateNSView(_ view: NSView, context: Context) { + (view as? Probe)?.silenceSelection() + } + } + + /// A view that does nothing but reach the table view it sits inside. + private final class Probe: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + silenceSelection() + } + + /// Turn off the highlight on the table view above this one. + /// + /// Walks the ancestors and tests each, rather than searching their + /// subtrees: from inside a row the table is two hops up, and searching a + /// table's subtree means walking every row it has realized. + /// + /// Finds nothing when called before the row is in the hierarchy, which the + /// first call after `makeNSView` always is. The call from + /// `viewDidMoveToWindow` is the one that lands. + func silenceSelection() { + var ancestor = superview + + while let current = ancestor { + if let table = current as? NSTableView { + table.selectionHighlightStyle = .none + return + } + ancestor = current.superview + } + } + } +} diff --git a/apps/macos/Sources/Markdown.swift b/apps/macos/Sources/Markdown.swift new file mode 100644 index 000000000..317b8eff0 --- /dev/null +++ b/apps/macos/Sources/Markdown.swift @@ -0,0 +1,460 @@ +import AppKit +import Foundation + +/// Markdown, turned into text a TextKit view can draw. +/// +/// Foundation's own parser does the reading: `AttributedString(markdown:)` with +/// full syntax records block structure in the `presentationIntent` attribute and +/// inline styling in `inlinePresentationIntent`. It records and does not render, +/// so the work here is the translation — intents into fonts, colours, paragraph +/// styles and list markers. +/// +/// Foundation leaves out the separators between blocks: two paragraphs come back +/// as adjacent runs with nothing between them. The newlines are put back here, +/// which is also what makes block spacing this file's to decide. +enum Markdown { + /// `source` as attributed text, with its block structure drawn. + /// + /// Text that cannot be parsed is returned as itself in the body style, so a + /// malformed message still shows its content. + static func attributed(_ source: String, style: MarkdownStyle) -> NSAttributedString { + let parsed = parse(source) + let output = NSMutableAttributedString() + + // Which list items have had their bullet drawn. A list item holding two + // paragraphs is two blocks, and only the first of them is marked. + var marked: Set = [] + + // The table row being gathered. Every cell is a block of its own, and a + // row is one line of tab-separated cells, so the cells are held until the + // row they belong to ends. + var row: TableRow? + + for block in blocks(of: parsed) { + let components = block.intent?.components ?? [] + + if let cell = tableCell(in: components) { + if row?.identity != cell.row { + flush(&row, into: output, style: style) + row = TableRow( + identity: cell.row, columns: cell.columns, isHeader: cell.isHeader) + } + + row?.cells.append( + content( + of: block, in: parsed, + font: cell.isHeader ? style.body.with(.bold) : style.body, + colour: style.text, style: style, inCodeBlock: false + ) + ) + continue + } + + flush(&row, into: output, style: style) + output.append(rendered(block, of: parsed, style: style, marked: &marked)) + } + + flush(&row, into: output, style: style) + + // Every block ends with the newline separating it from the next, so the + // last one leaves a trailing empty line. + if output.length > 0 { + output.deleteCharacters(in: NSRange(location: output.length - 1, length: 1)) + } + + return output + } + + /// One run of characters sharing a block intent. + private struct Block { + /// What the parser said this block is, absent for text it left unmarked. + let intent: PresentationIntent? + + /// Where the block sits in the parsed string. + let range: Range + } + + /// One row of a table, gathered cell by cell. + /// + /// Foundation reports a table as one block per cell, each carrying the row and + /// the table above it. A row is drawn as a single paragraph of tab-separated + /// cells, so the cells are collected until the row changes. + private struct TableRow { + /// The row's own identity, which is what says a cell belongs to it. + let identity: Int + + /// The table's columns, in order, carrying the alignment each was + /// declared with. + let columns: [PresentationIntent.TableColumn] + + /// Whether this is the header row, which is drawn in bold. + let isHeader: Bool + + var cells: [NSAttributedString] = [] + } + + /// What a list item's marker is, and whether it has been drawn yet. + private struct ListItem { + let ordinal: Int + let identity: Int + let ordered: Bool + } + + private static func parse(_ source: String) -> AttributedString { + let parsed = try? AttributedString( + markdown: source, + options: .init( + allowsExtendedAttributes: false, + interpretedSyntax: .full, + failurePolicy: .returnPartiallyParsedIfPossible + ) + ) + + return parsed ?? AttributedString(source) + } + + /// The parsed string cut into blocks. + /// + /// Adjacent runs belong to the same block when they carry the same intent: + /// every block the parser produces has an identity of its own, so two + /// neighbouring list items compare unequal even though both are paragraphs + /// in an unordered list. + private static func blocks(of parsed: AttributedString) -> [Block] { + var blocks: [Block] = [] + + for run in parsed.runs { + if let last = blocks.last, last.intent == run.presentationIntent { + blocks[blocks.count - 1] = Block( + intent: last.intent, + range: last.range.lowerBound.. + ) -> NSAttributedString { + let components = block.intent?.components ?? [] + let leaf = components.first?.kind + let item = listItem(in: components) + let isCodeBlock = if case .codeBlock = leaf { true } else { false } + + let font = blockFont(leaf, style: style) + let colour = blockColour(leaf, quoted: quoteDepth(in: components), style: style) + let paragraph = paragraphStyle( + leaf: leaf, + indent: style.indent + * CGFloat(listDepth(in: components) + quoteDepth(in: components)), + marked: item != nil, + style: style + ) + + let content = NSMutableAttributedString() + + if let item, marked.insert(item.identity).inserted { + content.append( + NSAttributedString( + string: "\(item.ordered ? "\(item.ordinal)." : "•")\t", + attributes: [.font: font, .foregroundColor: colour] + ) + ) + } + + content.append( + self.content( + of: block, in: parsed, font: font, colour: colour, style: style, + inCodeBlock: isCodeBlock) + ) + + // A fenced block's content keeps the newline before its closing fence, + // which would draw an empty last line inside the block. + if isCodeBlock { + while content.string.hasSuffix("\n") { + content.deleteCharacters(in: NSRange(location: content.length - 1, length: 1)) + } + } + + content.append(NSAttributedString(string: "\n", attributes: [.font: font])) + content.addAttribute( + .paragraphStyle, value: paragraph, + range: NSRange(location: 0, length: content.length)) + + if isCodeBlock { + content.addAttribute( + .backgroundColor, value: style.codeBackground, + range: NSRange(location: 0, length: content.length)) + } + + return content + } + + /// Draw the gathered row, if there is one, and forget it. + /// + /// Cells are separated by tabs and the paragraph carries one stop per column + /// boundary, so a cell begins where its column does. The stop takes the + /// alignment the column was declared with, which is the one piece of table + /// styling the source actually states — `---:` in the separator row right- + /// aligns a column of numbers, and Foundation reports it. + private static func flush( + _ row: inout TableRow?, into output: NSMutableAttributedString, style: MarkdownStyle + ) { + guard let gathered = row, !gathered.cells.isEmpty else { + row = nil + return + } + + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = style.lineSpacing + // A table reads as one block, so the space goes after the last row rather + // than between every pair of them. The rows of one table are consecutive, + // and whatever follows opens with its own spacing. + paragraph.paragraphSpacing = 0 + paragraph.tabStops = gathered.columns.indices.dropFirst().map { column in + NSTextTab( + textAlignment: alignment(of: gathered.columns[column]), + location: CGFloat(column) * style.tableColumnWidth + ) + } + + let line = NSMutableAttributedString() + for (column, cell) in gathered.cells.enumerated() { + if column > 0 { + line.append(NSAttributedString(string: "\t")) + } + line.append(cell) + } + + line.append(NSAttributedString(string: "\n", attributes: [.font: style.body])) + line.addAttribute( + .paragraphStyle, value: paragraph, range: NSRange(location: 0, length: line.length)) + + output.append(line) + row = nil + } + + /// How a column's cells sit against their tab stop. + private static func alignment( + of column: PresentationIntent.TableColumn + ) + -> NSTextAlignment + { + switch column.alignment { + case .left: .left + case .center: .center + case .right: .right + @unknown default: .left + } + } + + /// The row and table a cell belongs to, or `nil` when the block is not a cell. + /// + /// Components run innermost first, so a cell's are the cell, then its row, + /// then the table. + private static func tableCell( + in components: [PresentationIntent.IntentType] + ) + -> (row: Int, columns: [PresentationIntent.TableColumn], isHeader: Bool)? + { + guard let leaf = components.first?.kind else { return nil } + guard case .tableCell = leaf else { return nil } + guard components.count >= 3, case .table(let columns) = components[2].kind else { + return nil + } + + let isHeader: Bool + switch components[1].kind { + case .tableHeaderRow: isHeader = true + case .tableRow: isHeader = false + default: return nil + } + + return (components[1].identity, columns, isHeader) + } + + /// A block's runs, styled inline over a base font and colour. + private static func content( + of block: Block, + in parsed: AttributedString, + font: NSFont, + colour: NSColor, + style: MarkdownStyle, + inCodeBlock: Bool + ) -> NSAttributedString { + let content = NSMutableAttributedString() + + for run in parsed[block.range].runs { + content.append( + inline( + run, text: String(parsed[run.range].characters), font: font, + colour: colour, style: style, inCodeBlock: inCodeBlock) + ) + } + + return content + } + + /// One run of a block, with its inline styling applied over the block's. + private static func inline( + _ run: AttributedString.Runs.Run, + text: String, + font: NSFont, + colour: NSColor, + style: MarkdownStyle, + inCodeBlock: Bool + ) -> NSAttributedString { + let intent = run.inlinePresentationIntent ?? [] + var font = font + + if intent.contains(.stronglyEmphasized) { + font = font.with(.bold) + } + if intent.contains(.emphasized) { + font = font.with(.italic) + } + + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: colour, + ] + + // Only a span inside prose: the whole of a fenced block is already + // monospaced and already sitting on the code background. + if intent.contains(.code), !inCodeBlock { + attributes[.font] = style.monospaced + attributes[.foregroundColor] = style.codeText + attributes[.backgroundColor] = style.codeBackground + } + + if intent.contains(.strikethrough) { + attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + } + + if let url = run.link { + attributes[.link] = url + attributes[.foregroundColor] = style.link + attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + } + + // A hard break carries the text it was written with — two spaces, or a + // backslash — and means a new line inside the same paragraph. + let text = intent.contains(.lineBreak) ? "\n" : text + + return NSAttributedString(string: text, attributes: attributes) + } + + /// The innermost list item enclosing a block, if it is in a list at all. + /// + /// Components run innermost first, so the list an item belongs to is the + /// component after it, and that is what says whether the marker is a bullet + /// or a number. + private static func listItem(in components: [PresentationIntent.IntentType]) -> ListItem? { + guard + let index = components.firstIndex(where: { + if case .listItem = $0.kind { true } else { false } + }), + case .listItem(let ordinal) = components[index].kind + else { + return nil + } + + let enclosing = components.dropFirst(index + 1).first?.kind + let ordered = if case .orderedList = enclosing { true } else { false } + + return ListItem( + ordinal: ordinal, identity: components[index].identity, ordered: ordered) + } + + private static func listDepth(in components: [PresentationIntent.IntentType]) -> Int { + components.count { + switch $0.kind { + case .orderedList, .unorderedList: true + default: false + } + } + } + + private static func quoteDepth(in components: [PresentationIntent.IntentType]) -> Int { + components.count { + if case .blockQuote = $0.kind { true } else { false } + } + } + + private static func blockFont( + _ leaf: PresentationIntent.Kind?, style: MarkdownStyle + ) -> NSFont { + switch leaf { + case .header(let level): + NSFont.systemFont(ofSize: style.headingSize(level), weight: .semibold) + case .codeBlock: + style.monospaced + default: + style.body + } + } + + private static func blockColour( + _ leaf: PresentationIntent.Kind?, quoted: Int, style: MarkdownStyle + ) -> NSColor { + switch leaf { + case .codeBlock: style.codeText + case .thematicBreak: style.secondary + default: quoted > 0 ? style.secondary : style.text + } + } + + private static func paragraphStyle( + leaf: PresentationIntent.Kind?, + indent: CGFloat, + marked: Bool, + style: MarkdownStyle + ) -> NSParagraphStyle { + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = style.lineSpacing + paragraph.paragraphSpacing = style.blockSpacing + paragraph.firstLineHeadIndent = indent + paragraph.headIndent = indent + + // A heading opens a section, so it wants air above it as well as below. + if case .header = leaf { + paragraph.paragraphSpacingBefore = style.blockSpacing + } + + if case .thematicBreak = leaf { + paragraph.alignment = .center + } + + // The marker hangs in the indent its own level added, and a tab puts the + // text back at the indent — so a wrapped line lines up under the first + // rather than under the bullet. + if marked { + paragraph.firstLineHeadIndent = max(indent - style.indent, 0) + paragraph.tabStops = [NSTextTab(textAlignment: .left, location: max(indent, 1))] + paragraph.defaultTabInterval = style.indent + } + + return paragraph + } +} + +extension NSFont { + /// This font with `traits` added to whatever it already has. + /// + /// Through the descriptor rather than `NSFontManager`, which is main-actor + /// bound and would isolate the whole renderer to the main actor for the sake + /// of making one word bold. + func with(_ traits: NSFontDescriptor.SymbolicTraits) -> NSFont { + let descriptor = fontDescriptor.withSymbolicTraits( + fontDescriptor.symbolicTraits.union(traits)) + + return NSFont(descriptor: descriptor, size: pointSize) ?? self + } +} diff --git a/apps/macos/Sources/MarkdownStyle.swift b/apps/macos/Sources/MarkdownStyle.swift new file mode 100644 index 000000000..0629e941b --- /dev/null +++ b/apps/macos/Sources/MarkdownStyle.swift @@ -0,0 +1,104 @@ +import AppKit + +/// The fonts, colours and metrics block markdown is drawn with. +/// +/// Passed in rather than read from ``Theme`` inside the renderer, so the +/// translation from markdown to attributes can be checked against fixed numbers +/// without a running app deciding what "body text" resolves to. +struct MarkdownStyle { + /// Prose, and the size every other size is derived from. + var body: NSFont + + /// Code spans and code blocks. + var monospaced: NSFont + + /// What prose is drawn in. + var text: NSColor + + /// What a block quote and a thematic break are drawn in. + var secondary: NSColor + + /// Behind a code span or a code block. + var codeBackground: NSColor + + /// A code span's or code block's text. + var codeText: NSColor + + /// A link's text, which is also what underlines it. + var link: NSColor + + /// How far one level of list or quote nesting indents. + var indent: CGFloat + + /// How wide one column of a table is. + /// + /// Fixed rather than measured. Measuring would mean laying every cell out to + /// find the widest, at a width the container has not settled on yet, and + /// re-doing it on every resize — for a reader, not an editor. A column wide + /// enough for a short phrase is what a plain-text table gives and is legible + /// at the sizes JP transcripts use. + var tableColumnWidth: CGFloat + + /// The gap left below a block, before the next one. + var blockSpacing: CGFloat + + /// How much taller than its font a line of prose is drawn. + var lineSpacing: CGFloat + + /// The gap above a message that follows another in the same turn. + var eventSpacing: CGFloat + + /// The gap above the first message of a turn. + /// + /// Wider than ``eventSpacing``, because it is the only thing separating one + /// turn from the last. + var turnSpacing: CGFloat + + /// The app's palette, at the reading size. + /// + /// `appearance` decides which half of each ``ThemeColor`` is taken, because a + /// colour baked into an attributed string is resolved once when the string is + /// built rather than each time it is drawn. + @MainActor + static func reading(in appearance: NSAppearance) -> MarkdownStyle { + let size = NSFont.systemFontSize + 1 + + return MarkdownStyle( + body: .systemFont(ofSize: size), + monospaced: .monospacedSystemFont(ofSize: size - 1, weight: .regular), + text: resolved(Theme.bodyText, in: appearance), + secondary: resolved(Theme.secondaryText, in: appearance), + codeBackground: resolved(Theme.inlineCodeBackground, in: appearance), + codeText: resolved(Theme.inlineCodeText, in: appearance), + link: resolved(Theme.accent, in: appearance), + indent: 22, + tableColumnWidth: 150, + blockSpacing: 10, + lineSpacing: 3, + eventSpacing: 18, + turnSpacing: 40 + ) + } + + /// How large a heading of `level` is drawn, relative to ``body``. + /// + /// Levels past the third are the body size in bold, which is what a document + /// nested that deep wants: another distinct size would be a difference nobody + /// can see. + func headingSize(_ level: Int) -> CGFloat { + let scale: CGFloat = + switch level { + case 1: 1.6 + case 2: 1.35 + case 3: 1.15 + default: 1 + } + + return (body.pointSize * scale).rounded() + } + + /// One palette colour, fixed to the half `appearance` shows. + private static func resolved(_ colour: ThemeColor, in appearance: NSAppearance) -> NSColor { + ThemeColor.srgb(colour.value(under: appearance)) + } +} diff --git a/apps/macos/Sources/RecentWorkspaces.swift b/apps/macos/Sources/RecentWorkspaces.swift new file mode 100644 index 000000000..e4279c0b7 --- /dev/null +++ b/apps/macos/Sources/RecentWorkspaces.swift @@ -0,0 +1,82 @@ +import Foundation +import Observation + +/// The workspaces opened before, most recent first. +/// +/// Reads and writes the list through a ``RecentsStore``, and owns the two rules +/// that apply whichever store is in use: paths are canonicalized on the way in, +/// and directories that have gone away are dropped on the way out. +/// +/// The `File ▸ Open Recent` menu is built from ``urls`` explicitly. AppKit manages +/// that menu on its own only for a document-based app, which this is not. +@MainActor +@Observable +final class RecentWorkspaces { + private(set) var urls: [URL] = [] + + private let store: any RecentsStore + + init(store: any RecentsStore) { + self.store = store + urls = Self.pruned(store.urls()) + } + + /// A list backed by whichever store the app's environment selects. + convenience init() { + self.init(store: DebugState.defaultStore()) + } + + /// Record a workspace as opened, moving it to the front. + /// + /// The URL is canonicalized first. `NSDocumentController` resolves symlinks + /// when it stores one, and macOS symlinks `/var` and `/tmp`, so noting a URL + /// as given would put a path in the menu that never matches the one a window + /// was opened with — and windows are keyed by path, so the same workspace + /// would open twice. + func note(_ url: URL) { + store.note(url.canonicalized) + urls = Self.pruned(store.urls()) + } + + /// Forget every recorded workspace. + func clear() { + store.clear() + urls = Self.pruned(store.urls()) + } + + /// The recorded workspaces that still exist on disk, canonicalized. + /// + /// A directory can be deleted or unmounted between launches, and offering to + /// open one that is gone only produces an error the user cannot act on. + private static func pruned(_ urls: [URL]) -> [URL] { + urls.map(\.canonicalized).filter { url in + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists( + atPath: url.path(percentEncoded: false), + isDirectory: &isDirectory + ) + return exists && isDirectory.boolValue + } + } +} + +extension URL { + /// The URL with symlinks resolved and any trailing slash dropped, so two + /// spellings of one directory compare equal. + /// + /// `URL(fileURLWithPath:)` checks the filesystem and marks an existing + /// directory as one, which puts a trailing slash into every path read back out. + /// Windows are keyed by that path, so a list holding `/a/b/` while a window is + /// keyed by `/a/b` lets one workspace open twice. + /// + /// `isDirectory: false` is what keeps the slash off, and is not a claim about + /// what is at the path: it declares the spelling rather than letting the + /// filesystem pick one, which is the whole point of a canonical form. + var canonicalized: URL { + let path = resolvingSymlinksInPath().path(percentEncoded: false) + let trimmed = + path.count > 1 && path.hasSuffix("/") ? String(path.dropLast()) : path + + return URL(fileURLWithPath: trimmed, isDirectory: false) + } +} diff --git a/apps/macos/Sources/RecentsStore.swift b/apps/macos/Sources/RecentsStore.swift new file mode 100644 index 000000000..b335a4930 --- /dev/null +++ b/apps/macos/Sources/RecentsStore.swift @@ -0,0 +1,126 @@ +import AppKit +import Foundation + +/// Where the recent-workspace list is kept. +/// +/// Storing the list is all this covers. Canonicalizing paths and dropping +/// directories that have gone away are policy ``RecentWorkspaces`` applies above +/// it, so every implementation agrees on them. +@MainActor +protocol RecentsStore { + /// The recorded workspaces, most recent first. + func urls() -> [URL] + + /// Record a workspace as opened, moving it to the front. + func note(_ url: URL) + + /// Forget every recorded workspace. + func clear() +} + +/// The recent-workspace list as AppKit keeps it. +/// +/// `NSDocumentController`'s list persists across launches and is shared with the +/// system, which is what puts the app's workspaces in its Dock menu. It is keyed +/// by bundle identifier, so every process running this app reads and writes one +/// list — the test bundle included, since the tests run hosted by the app. +struct DocumentControllerRecents: RecentsStore { + func urls() -> [URL] { + NSDocumentController.shared.recentDocumentURLs + } + + func note(_ url: URL) { + NSDocumentController.shared.noteNewRecentDocumentURL(url) + } + + func clear() { + NSDocumentController.shared.clearRecentDocuments(nil) + } +} + +/// The recent-workspace list kept as JSON at a path of the caller's choosing. +/// +/// Paths are stored as an array of strings, most recent first, so a harness can +/// read the list it drove the app into directly rather than through the +/// accessibility tree: +/// +/// ```json +/// ["/Users/jean/Projects/jp", "/tmp/probe-ws"] +/// ``` +/// +/// Nothing is cached: every call reads the file. The list holds ten paths and a +/// harness may rewrite it between launches, so there is nothing here worth the +/// risk of serving a stale answer. +struct FileRecents: RecentsStore { + /// The JSON file backing the list, created on the first ``note(_:)``. + let path: URL + + /// How many paths the list keeps, matching what `NSDocumentController` stores + /// by default. + static let capacity = 10 + + /// The recorded workspaces, most recent first. + /// + /// A file that is not there yet is an empty list rather than an error: that is + /// the state before anything has been opened. A file that is there but + /// unreadable is reported and also read as empty, because refusing to produce + /// a list would cost the window its workspace. + /// + /// `isDirectory: false` keeps the round-trip verbatim. The plain + /// `URL(fileURLWithPath:)` consults the filesystem and appends a slash to a + /// path that names a directory, so a path would read back spelled differently + /// from how it was written and ``note(_:)`` would stop recognizing it. + func urls() -> [URL] { + guard let data = try? Data(contentsOf: path) else { + return [] + } + + do { + let paths = try JSONDecoder().decode([String].self, from: data) + return paths.map { URL(fileURLWithPath: $0, isDirectory: false) } + } catch { + report("could not read \(path.path(percentEncoded: false)): \(error)") + return [] + } + } + + func note(_ url: URL) { + let noted = url.path(percentEncoded: false) + var paths = urls().map { $0.path(percentEncoded: false) } + + // Dropping any earlier spelling of the same path before inserting is what + // makes this a move-to-front rather than a second entry. + paths.removeAll { $0 == noted } + paths.insert(noted, at: 0) + + write(Array(paths.prefix(Self.capacity))) + } + + func clear() { + write([]) + } + + private func write(_ paths: [String]) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted] + + do { + try FileManager.default.createDirectory( + at: path.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try encoder.encode(paths).write(to: path, options: .atomic) + } catch { + report("could not write \(path.path(percentEncoded: false)): \(error)") + } + } + + /// Note a failure on stderr. + /// + /// The list is a convenience, and a launch that cannot persist it should still + /// open a window. Reporting rather than throwing keeps that true, and stderr + /// is where a harness driving the app is already reading. + private func report(_ message: String) { + FileHandle.standardError.write(Data("recents: \(message)\n".utf8)) + } +} diff --git a/apps/macos/Sources/SearchField.swift b/apps/macos/Sources/SearchField.swift new file mode 100644 index 000000000..cc4bc9201 --- /dev/null +++ b/apps/macos/Sources/SearchField.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// The box that narrows the conversation list. +/// +/// Built rather than styled, because none of the stock text field styles gives a +/// glyph inside the field, and the bordered ones draw a focus ring the design +/// does not have. +/// +/// Carries no outer padding, so a caller can place it against the window buttons +/// and give it the height it needs to line up with them. +struct SearchField: View { + /// What has been typed. + @Binding var text: String + + /// The corner radius of the field and of its border, which have to match or + /// the stroke cuts across the fill. + private static let radius: CGFloat = 6 + + var body: some View { + HStack(spacing: 5) { + // Hidden from the accessibility tree: it says nothing the field's own + // label does not, and SwiftUI otherwise publishes an SF Symbol as an + // element identified by its symbol name — a name nothing here chose, + // sitting in the tree beside the ones that were. + Image(systemName: "magnifyingglass") + .foregroundStyle(Theme.secondaryText.color) + .accessibilityHidden(true) + + // The accessibility modifiers sit directly on the field, ahead of + // the layout ones, so they cannot land on a wrapper `padding` + // introduces. + // + // A collapsed sidebar takes the field out of the accessibility tree + // entirely, along with the list. A driver that cannot find either + // should check the sidebar is showing before concluding an + // identifier is missing. + // An empty title, with the placeholder drawn below instead: a + // `TextField`'s own placeholder takes the system's grey and no + // modifier reaches it, which leaves it several shades lighter than + // every other piece of secondary text in the sidebar. + TextField("", text: $text) + .accessibilityLabel("Filter conversations") + .accessibilityIdentifier(AccessibilityID.Sidebar.filter) + .textFieldStyle(.plain) + .foregroundStyle(Theme.bodyText.color) + .background(alignment: .leading) { + if text.isEmpty { + // Never a click target, or it would swallow the click that + // is meant to put the caret in the field. + Text(verbatim: "Filter") + .foregroundStyle(Theme.secondaryText.color) + .allowsHitTesting(false) + // The field already carries this as its label, so + // publishing it again would put two elements in the + // tree saying the same thing. + .accessibilityHidden(true) + } + } + + // Always there, whether or not there is anything to clear. A control + // that comes and goes with what has been typed moves the text's right + // edge as it appears, and the field is the one part of the sidebar + // that should not shift while somebody is typing into it. + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Theme.secondaryText.color) + } + .accessibilityLabel("Clear the filter") + .accessibilityIdentifier(AccessibilityID.Sidebar.filterClear) + .buttonStyle(.plain) + } + .font(.system(size: 13)) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: Self.radius) + .fill(Theme.searchFieldBackground.color) + // The field and the sidebar are the same colour in both + // appearances, so the border is the only thing that says where + // the field is. + .overlay( + RoundedRectangle(cornerRadius: Self.radius) + .strokeBorder(Theme.paneDivider.color, lineWidth: 1) + ) + ) + } +} diff --git a/apps/macos/Sources/Theme.swift b/apps/macos/Sources/Theme.swift new file mode 100644 index 000000000..905dfcd5e --- /dev/null +++ b/apps/macos/Sources/Theme.swift @@ -0,0 +1,112 @@ +import AppKit +import SwiftUI + +/// One colour of the palette, in both appearances. +/// +/// Held as sRGB numbers rather than as `Color`s, so a colour is defined in one +/// place for both appearances and the palette can be read and compared without +/// a running app. +struct ThemeColor: Equatable, Sendable { + /// The value used in light appearance, as `0xRRGGBB`. + let light: UInt32 + + /// The value used in dark appearance, as `0xRRGGBB`. + let dark: UInt32 + + /// The SwiftUI colour to draw with. + /// + /// Resolves per appearance as it draws rather than being fixed when it is + /// built: a window moved between appearances redraws from the same `Color` + /// value and has to pick up the other half. + var color: Color { + Color(nsColor: nsColor) + } + + /// The AppKit colour behind ``color``. + var nsColor: NSColor { + let (light, dark) = (self.light, self.dark) + + return NSColor(name: nil) { appearance in + Self.srgb(appearance.isDark ? dark : light) + } + } + + /// The value this shows under `appearance`. + func value(under appearance: NSAppearance) -> UInt32 { + appearance.isDark ? dark : light + } + + /// An opaque sRGB colour from `0xRRGGBB`. + static func srgb(_ hex: UInt32) -> NSColor { + NSColor( + srgbRed: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255, + alpha: 1 + ) + } +} + +extension NSAppearance { + /// Whether this is one of the dark appearances. + /// + /// Matched rather than compared by name, because the accessibility variants + /// (`accessibilityHighContrastDarkAqua` and friends) are dark too and have + /// names of their own. + var isDark: Bool { + bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + } +} + +/// The app's colours, in one place. +/// +/// Every surface and every piece of text picks its colour from here rather than +/// from a system semantic colour, because the app's appearance is a design +/// decision that has to hold across both appearances and both windows. +enum Theme { + /// Behind the conversation list. + static let sidebarBackground = ThemeColor(light: 0xFF_FFFF, dark: 0x1D_1E20) + + /// Behind the selected row of the conversation list. + static let selectedRowBackground = ThemeColor(light: 0xF4_F5F7, dark: 0x2E_2E30) + + /// The line between the sidebar and the transcript, and the search field's + /// border. + static let paneDivider = ThemeColor(light: 0xD9_D9D9, dark: 0x2C_2D2E) + + /// The line between two rows of the conversation list. + /// + /// Lighter than ``paneDivider``, because there is one of those and dozens of + /// these: at the pane divider's weight the list reads as a grid. + static let rowSeparator = ThemeColor(light: 0xE4_E5E6, dark: 0x2C_2D2E) + + /// Behind the search field. + static let searchFieldBackground = ThemeColor(light: 0xFF_FFFF, dark: 0x1D_1E20) + + /// Behind the transcript. + static let editorBackground = ThemeColor(light: 0xFF_FFFF, dark: 0x1D_1E20) + + /// Prose, and anything else a person is meant to read. + static let bodyText = ThemeColor(light: 0x44_4444, dark: 0xCC_DBE5) + + /// Dates, counts, speaker names — text that labels rather than says. + static let secondaryText = ThemeColor(light: 0x88_8888, dark: 0xA2_A3A4) + + /// The one colour that draws the eye: the pin glyph, and controls that tint. + /// + /// Red in light appearance and blue in dark, which is not a mistake — it is + /// what the design calls for. + static let accent = ThemeColor(light: 0xDD_4D4F, dark: 0x45_A2E5) + + /// Behind an inline code span. + static let inlineCodeBackground = ThemeColor(light: 0xF4_F5F7, dark: 0x2E_2E30) + + /// An inline code span's text. + static let inlineCodeText = ThemeColor(light: 0x44_4444, dark: 0xDF_E0E0) + + /// Behind a tag pill. + static let tagBackground = ThemeColor(light: 0xE4_E5E6, dark: 0x46_4647) + + /// A tag pill's text. + static let tagText = ThemeColor(light: 0x44_4444, dark: 0xDF_E0E0) +} diff --git a/apps/macos/Sources/Trace.swift b/apps/macos/Sources/Trace.swift new file mode 100644 index 000000000..9ceac51a2 --- /dev/null +++ b/apps/macos/Sources/Trace.swift @@ -0,0 +1,492 @@ +import Foundation +import os + +/// What the app records about its own work. +/// +/// Two sinks for the same intervals. `OSSignposter` always, so attaching +/// Instruments to any running instance shows them; and a line of JSON per event +/// to `/trace.jsonl` when a harness has pointed the app at a +/// directory, which is what the `debug_app_*` tools read back. +/// +/// The file is its own channel rather than stdout or stderr, because those two +/// are reported as deltas on every snapshot: a trace stream on either would bury +/// what AppKit had to say under the app's own instrumentation. +/// +/// With `JP_DEBUG_STATE_DIR` unset nothing is opened and no file is created, and +/// the only cost left is a signpost and a timestamp per interval. +enum Trace { + /// The trace file, inside the debug state directory. + static let fileName = "trace.jsonl" + + /// What an event is attributed to when the caller names nothing more + /// specific. + static let defaultTarget = "JP" + + /// Where the JSON goes, or `nil` when the app was launched as it ships. + /// + /// Resolved once. A harness sets the variable before launch and never + /// changes it, and re-reading the environment per event would cost more than + /// writing the line. + private static let sink = TraceWriter(directory: DebugState.directory, fileName: fileName) + + /// The signpost stream Instruments shows. + /// + /// Named for the app rather than for the slot a driven copy runs under, so + /// every instance appears under one subsystem. + static let signposter = OSSignposter( + subsystem: "computer.jp.jean-pierre", category: "trace") + + /// The signpost every interval is filed under. + /// + /// `OSSignposter` takes a `StaticString`, which an interval's name is not, so + /// the name travels in the signpost's message instead. + static let signpostName: StaticString = "interval" + + /// Whether events are being written to a file. + static var isRecording: Bool { sink != nil } + + /// Where the file is, once there is one. + static var url: URL? { sink?.url } + + /// Record one event. + static func event( + _ message: String, + target: String = defaultTarget, + level: TraceLevel = .info, + fields: TraceFields = [], + spans: [String] = [] + ) { + guard isRecording else { return } + + let line = line( + timestamp: timestamp(Date()), + level: level, + target: target, + message: message, + fields: fields, + spans: spans + ) + + guard let line else { return } + write(line) + } + + /// Append a line that has already been built. + /// + /// For a caller assembling its own lines, such as one turning durations + /// reported from elsewhere into events. ``event(_:target:level:fields:spans:)`` + /// is the ordinary way in. + static func write(_ line: String) { + sink?.append(line) + } + + /// Start timing a piece of work, to be ended through the returned token. + /// + /// `fields` are written when the interval ends, before the ones `end` is + /// given, so an interval's own context reads ahead of its result. + static func interval( + _ name: String, + target: String = defaultTarget, + fields: TraceFields = [], + spans: [String] = [] + ) -> TraceInterval { + TraceInterval( + name: name, + target: target, + fields: fields, + spans: spans, + started: mach_absolute_time(), + signpost: signposter.beginInterval( + signpostName, id: signposter.makeSignpostID(), "\(name, privacy: .public)") + ) + } + + /// Run `work` as an interval named `name`, and return what it produced. + static func measuring( + _ name: String, + target: String = defaultTarget, + fields: TraceFields = [], + spans: [String] = [], + _ work: () -> T + ) -> T { + let token = interval(name, target: target, fields: fields, spans: spans) + let value = work() + token.end() + return value + } + + /// Write the event an interval ends with. + /// + /// The footprint is sampled here rather than in ``TraceInterval/end(_:)`` so + /// an app running without a state directory never makes the call. + static func record(_ interval: TraceInterval, elapsed ticks: UInt64, extra: TraceFields) { + guard isRecording else { return } + + var fields: TraceFields = [("duration_ms", .double(milliseconds(ticks)))] + fields.append(contentsOf: interval.fields) + fields.append(contentsOf: extra) + if let footprint = footprintMB() { + fields.append(("footprint_mb", .int(footprint))) + } + + event( + interval.name, + target: interval.target, + fields: fields, + spans: interval.spans + ) + } + + /// Record the pair that lines this timeline up with one measured on the mach + /// clock. + /// + /// A trace taken in Instruments carries mach timestamps and no wall clock; + /// this file carries wall clocks and no mach timestamps. One reading of both + /// at the same instant is what lets the two be laid over each other. + static func origin() { + let now = Date() + let ticks = mach_absolute_time() + let timebase = MachTimebase.current + + event( + "trace.origin", + target: "JP.Trace", + fields: [ + ("mach_absolute_time", .int(Int(clamping: ticks))), + ("unix_time_ns", .int(Int(now.timeIntervalSince1970 * 1_000_000_000))), + ("timebase_numer", .int(Int(timebase.numerator))), + ("timebase_denom", .int(Int(timebase.denominator))), + ] + ) + } + + /// One event as the line that goes in the file, or `nil` if it cannot be + /// encoded. + static func line( + timestamp: String, + level: TraceLevel, + target: String, + message: String, + fields: TraceFields, + spans: [String] + ) -> String? { + var all: TraceFields = [("message", .string(message))] + all.append(contentsOf: fields) + + return TraceLine( + timestamp: timestamp, + level: level, + target: target, + fields: all, + spans: spans + ).encoded() + } + + /// `date` as RFC 3339 in UTC, to the microsecond. + /// + /// Formatted by hand because `ISO8601DateFormatter` stops at milliseconds, + /// and because a formatter is a reference type that would have to be shared + /// across every thread that ends an interval. + static func timestamp(_ date: Date) -> String { + let seconds = date.timeIntervalSince1970 + let whole = seconds.rounded(.down) + var epoch = time_t(whole) + var parts = tm() + gmtime_r(&epoch, &parts) + + // Rounded, not truncated: a `Date` holds seconds as a `Double`, and the + // microsecond a caller put in comes back a fraction of a microsecond + // short of itself. + let micros = min(Int(((seconds - whole) * 1_000_000).rounded()), 999_999) + + return String( + format: "%04d-%02d-%02dT%02d:%02d:%02d.%06dZ", + parts.tm_year + 1900, + parts.tm_mon + 1, + parts.tm_mday, + parts.tm_hour, + parts.tm_min, + parts.tm_sec, + micros + ) + } + + /// A span of `mach_absolute_time()` ticks in milliseconds, to the + /// microsecond. + static func milliseconds(_ ticks: UInt64) -> Double { + let nanoseconds = Double(MachTimebase.current.nanoseconds(ticks)) + return (nanoseconds / 1000).rounded() / 1000 + } + + /// What the process currently occupies, in MiB. + /// + /// `phys_footprint` is the number macOS itself judges a process by, and the + /// call to read it costs microseconds. Which call site allocated the bytes is + /// a different question, and needs a tool that costs several times the run. + static func footprintMB() -> Int? { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t( + MemoryLayout.size / MemoryLayout.size) + + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + + guard result == KERN_SUCCESS else { return nil } + return Int(clamping: info.phys_footprint / (1024 * 1024)) + } +} + +extension Trace { + /// The launch, held from the app's earliest code until its first window. + /// + /// Main-actor state rather than locked state: both ends of this particular + /// interval run on the main actor, and nothing else touches it. + @MainActor private static var launch: TraceInterval? + + /// Start the launch interval, and record the clock origin. + @MainActor + static func beginLaunch() { + origin() + launch = interval("app.launch", target: "JP.App") + } + + /// End the launch interval, if it is still open. + /// + /// Called by every window as it appears, and only the first one finds an + /// interval to end. + @MainActor + static func endLaunch() { + launch?.end() + launch = nil + } +} + +/// A started interval, ended by whoever holds it. +struct TraceInterval { + /// What the interval is called, written as the event's message. + let name: String + + /// What the event is attributed to. + let target: String + + /// Context written ahead of whatever `end` is given. + let fields: TraceFields + + /// The enclosing interval names, root first. + let spans: [String] + + /// When it started, on the mach clock. + let started: UInt64 + + /// The signpost half of the same interval. + let signpost: OSSignpostIntervalState + + /// Close the interval, writing how long it took and what the process now + /// occupies. + func end(_ extra: TraceFields = []) { + let elapsed = mach_absolute_time() &- started + Trace.signposter.endInterval(Trace.signpostName, signpost) + Trace.record(self, elapsed: elapsed, extra: extra) + } +} + +/// Severity, spelled as the trace format spells it. +enum TraceLevel: String, Sendable { + case trace = "TRACE" + case debug = "DEBUG" + case info = "INFO" + case warn = "WARN" + case error = "ERROR" +} + +/// What a trace field can hold. +enum TraceValue: Encodable, Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .int(let value): try container.encode(value) + case .double(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + } + } +} + +extension TraceValue: ExpressibleByStringLiteral { + init(stringLiteral value: String) { self = .string(value) } +} + +extension TraceValue: ExpressibleByIntegerLiteral { + init(integerLiteral value: Int) { self = .int(value) } +} + +extension TraceValue: ExpressibleByFloatLiteral { + init(floatLiteral value: Double) { self = .double(value) } +} + +extension TraceValue: ExpressibleByBooleanLiteral { + init(booleanLiteral value: Bool) { self = .bool(value) } +} + +/// The fields of one event, in the order they are written. +/// +/// An array rather than a dictionary because the order is part of what makes a +/// line readable, and because a pinned test compares the whole string. +typealias TraceFields = [(String, TraceValue)] + +/// One event, shaped as `tracing-subscriber::fmt::json()` writes it. +/// +/// `jp` writes this format under `JP_DEBUG=1` and the tooling already parses it, +/// so the app's timeline and jp's can be read together. +struct TraceLine { + let timestamp: String + let level: TraceLevel + let target: String + let fields: TraceFields + let spans: [String] + + /// The line as it goes in the file, or `nil` if a value cannot be encoded. + /// + /// Assembled key by key rather than handed to `JSONEncoder` whole, because a + /// keyed container writes its entries in an order Foundation chooses: the + /// timestamp lands in the middle, and a duration ahead of the message it + /// belongs to. Every scalar still goes through the encoder, so escaping is + /// Foundation's job and not this file's. + func encoded() -> String? { + let encoder = JSONEncoder() + + guard + let level = Self.encode(.string(level.rawValue), with: encoder), + let target = Self.encode(.string(target), with: encoder), + let timestamp = Self.encode(.string(timestamp), with: encoder), + let fields = encodedFields(with: encoder) + else { + return nil + } + + var line = "{\"timestamp\":\(timestamp),\"level\":\(level),\"target\":\(target)," + line.append("\"fields\":\(fields)") + + // Omitted when empty: the parser treats the key as optional, and most + // events are not nested inside anything. + if !spans.isEmpty, let spans = encodedSpans(with: encoder) { + line.append(",\"spans\":\(spans)") + } + + line.append("}") + return line + } + + private func encodedFields(with encoder: JSONEncoder) -> String? { + var entries: [String] = [] + entries.reserveCapacity(fields.count) + + for (name, value) in fields { + guard + let name = Self.encode(.string(name), with: encoder), + let value = Self.encode(value, with: encoder) + else { + return nil + } + + entries.append("\(name):\(value)") + } + + return "{\(entries.joined(separator: ","))}" + } + + private func encodedSpans(with encoder: JSONEncoder) -> String? { + var entries: [String] = [] + entries.reserveCapacity(spans.count) + + for span in spans { + guard let name = Self.encode(.string(span), with: encoder) else { return nil } + entries.append("{\"name\":\(name)}") + } + + return "[\(entries.joined(separator: ","))]" + } + + /// One value as its JSON representation. + private static func encode(_ value: TraceValue, with encoder: JSONEncoder) -> String? { + guard let data = try? encoder.encode(value) else { return nil } + return String(decoding: data, as: UTF8.self) + } +} + +/// The ratio turning `mach_absolute_time()` ticks into nanoseconds. +struct MachTimebase: Sendable { + let numerator: UInt32 + let denominator: UInt32 + + /// What this machine reports, read once. + static let current: MachTimebase = { + var info = mach_timebase_info_data_t() + mach_timebase_info(&info) + return MachTimebase(numerator: info.numer, denominator: info.denom) + }() + + func nanoseconds(_ ticks: UInt64) -> UInt64 { + ticks * UInt64(numerator) / UInt64(denominator) + } +} + +/// An append-only line sink, writable from any isolation domain. +/// +/// `@unchecked Sendable` rather than an actor: an interval ends wherever the +/// work it timed ends, and an actor would put an `await` at every one of those +/// call sites, changing the timing being measured. The file handle is only ever +/// touched with `lock` held, which is what makes the unchecked claim true. +final class TraceWriter: @unchecked Sendable { + /// The file being appended to. + let url: URL + + private let lock = NSLock() + private let handle: FileHandle + + /// Open `fileName` inside `directory`, creating both if they are missing. + /// + /// `nil` when no directory is given, which is how the app ships: nothing is + /// created and nothing is written. + init?(directory: URL?, fileName: String) { + guard let directory else { return nil } + + let manager = FileManager.default + let url = directory.appendingPathComponent(fileName) + let path = url.path(percentEncoded: false) + + try? manager.createDirectory(at: directory, withIntermediateDirectories: true) + if !manager.fileExists(atPath: path) { + guard manager.createFile(atPath: path, contents: nil) else { return nil } + } + + guard let handle = try? FileHandle(forWritingTo: url) else { return nil } + _ = try? handle.seekToEnd() + + self.url = url + self.handle = handle + } + + deinit { + try? handle.close() + } + + /// Append `line` and a newline. + /// + /// A failed write is dropped rather than reported: the app is being observed, + /// not driven by this, and a full disk is not a reason to interrupt what the + /// person at the keyboard is reading. + func append(_ line: String) { + lock.withLock { + try? handle.write(contentsOf: Data("\(line)\n".utf8)) + } + } +} diff --git a/apps/macos/Sources/TranscriptDocument.swift b/apps/macos/Sources/TranscriptDocument.swift new file mode 100644 index 000000000..78579e0fc --- /dev/null +++ b/apps/macos/Sources/TranscriptDocument.swift @@ -0,0 +1,81 @@ +import AppKit + +/// A conversation's turns, as one piece of attributed text. +/// +/// One string rather than one view per message, because a text view lays out +/// what its viewport needs and re-wraps incrementally, where a stack of views +/// each measure and wrap themselves and a width change costs the sum of them. +/// +/// Turn boundaries are drawn as space rather than as a rule: the gap above the +/// first message of a turn is wider than the gap between two messages inside +/// one, which is what separates them. +enum TranscriptDocument { + /// `turns` laid out for reading, oldest first. + /// + /// Empty when there is nothing to show, which a caller distinguishes from a + /// conversation it could not read. + static func attributed( + _ turns: [ConversationTurn], style: MarkdownStyle + ) -> NSAttributedString { + let document = NSMutableAttributedString() + + for turn in turns { + for (offset, event) in turn.events.enumerated() { + // The gap belongs above the speaker's name rather than below the + // message before it, so all of the spacing is decided in one + // place and none of it has to reach back into text the markdown + // renderer has already styled. + let above: CGFloat = + if document.length == 0 { 0 } else if offset == 0 { style.turnSpacing } else + { style.eventSpacing } + + document.append(speaker(event.speaker, above: above, style: style)) + append(Markdown.attributed(event.text, style: style), to: document) + } + } + + // Every message ends with the newline separating it from the next, so + // the last one leaves a trailing empty line. + if document.length > 0 { + document.deleteCharacters(in: NSRange(location: document.length - 1, length: 1)) + } + + return document + } + + /// Who is speaking, as the line above what they said. + private static func speaker( + _ name: String, above: CGFloat, style: MarkdownStyle + ) -> NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.paragraphSpacingBefore = above + paragraph.paragraphSpacing = 2 + + return NSAttributedString( + string: "\(name)\n", + attributes: [ + .font: NSFont.systemFont(ofSize: style.body.pointSize - 2, weight: .semibold), + .foregroundColor: style.secondary, + .paragraphStyle: paragraph, + ] + ) + } + + /// Append `message` and the newline that ends it. + /// + /// The newline carries the message's own trailing attributes, so it sits on + /// the same paragraph rather than opening an unstyled one of the default + /// font's height. + private static func append( + _ message: NSAttributedString, to document: NSMutableAttributedString + ) { + document.append(message) + + let attributes = + message.length > 0 + ? message.attributes(at: message.length - 1, effectiveRange: nil) + : [:] + + document.append(NSAttributedString(string: "\n", attributes: attributes)) + } +} diff --git a/apps/macos/Sources/TranscriptTextView.swift b/apps/macos/Sources/TranscriptTextView.swift new file mode 100644 index 000000000..db057346a --- /dev/null +++ b/apps/macos/Sources/TranscriptTextView.swift @@ -0,0 +1,382 @@ +import AppKit +import SwiftUI + +/// A text view that reports what a window drag asked of it. +/// +/// Whether the text re-wraps while the window is still moving is the difference +/// between the transcript feeling native and feeling like a screenshot that +/// catches up. It depends on the layout stack, and the two fail differently +/// enough that the count of frames the drag delivered is worth having either +/// way: a stale transcript with a high count is layout refusing to run, and a +/// stale transcript with a count of zero is AppKit serving cached pixels +/// instead of resizing the view at all. +private final class LiveWrappingTextView: NSTextView { + /// How many frames of the current drag changed this view's size at all. + private var frames = 0 + + /// How many of those changed its *width*. + /// + /// The one that matters: a container only re-wraps when the width it tracks + /// moves. A drag that delivers hundreds of frames of pure height change + /// would leave the text correctly un-re-wrapped, and counting frames alone + /// could not tell that apart from layout refusing to run. + private var widthChanges = 0 + + /// The width this view had when the drag began. + private var widthAtStart: CGFloat = 0 + + /// How many frames of the drag changed the text *container's* width. + /// + /// The link between a resized view and re-wrapped text. A container tracking + /// the view is supposed to follow it, and a container whose geometry changes + /// is what invalidates layout — so a view width that moves while this stays + /// still is the whole bug, and one that moves in step with it puts the fault + /// after this point. + private var containerChanges = 0 + + /// The container width seen at the previous frame. + private var lastContainerWidth: CGFloat = 0 + + /// The least of the document the layout manager had laid out at any frame + /// of the drag, as a character index. + /// + /// Contiguous layout fills from the start, so this is how far down the + /// document layout reached at its worst. The minimum rather than the last + /// value, because the last frame of a drag is the one most likely to have + /// caught up. + private var laidOutTo = Int.max + + /// How many times AppKit asked this view to draw during the drag. + private var draws = 0 + + /// The tallest rectangle AppKit asked it to draw, in points. + /// + /// Compared against the height of what is on screen. A number far short of + /// that is AppKit redrawing a strip and keeping the rest, which is what a + /// view is told to expect when it says its content survives a resize. + private var tallestDraw: CGFloat = 0 + + /// Whether AppKit may keep what this view already drew when it resizes. + /// + /// Overridden to `false`. Left to itself the answer is yes, and then a + /// narrowing drag exposes no new region, so nothing is marked dirty and the + /// cached pixels are simply clipped — the text underneath has re-wrapped + /// and nobody has been asked to draw it. + /// + /// The cost is redrawing the visible text on every frame of a drag, which + /// is the work being watched anyway. + override var preservesContentDuringLiveResize: Bool { false } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard inLiveResize else { return } + + draws += 1 + tallestDraw = max(tallestDraw, dirtyRect.height) + } + + override func viewWillStartLiveResize() { + super.viewWillStartLiveResize() + + frames = 0 + widthChanges = 0 + draws = 0 + tallestDraw = 0 + laidOutTo = Int.max + containerChanges = 0 + widthAtStart = frame.width + lastContainerWidth = textContainer?.size.width ?? 0 + } + + override func setFrameSize(_ newSize: NSSize) { + let before = frame.width + super.setFrameSize(newSize) + guard inLiveResize else { return } + + frames += 1 + if newSize.width != before { + widthChanges += 1 + } + + // The width a tracking container would take, handed to it directly. + // + // A text view passes its width to the container it is tracked by, and + // does not do it while a resize is in progress: the container keeps the + // width the drag started from until the mouse comes up. Nothing then + // changes the container's geometry, nothing invalidates layout, and the + // view faithfully redraws lines wrapped to a width the window no longer + // has. + // + // Setting it here is what a tracking container would have done, one + // frame earlier. The inset is counted twice because it applies to both + // edges. + if let container = textContainer { + let wanted = newSize.width - textContainerInset.width * 2 + if container.size.width != wanted { + container.size = NSSize(width: wanted, height: container.size.height) + } + } + + let containerWidth = textContainer?.size.width ?? 0 + if containerWidth != lastContainerWidth { + containerChanges += 1 + lastContainerWidth = containerWidth + } + + // Only on TextKit 2, which lays out around the viewport and leaves the + // rest estimated. Contiguous layout has no viewport to nudge. + // + // Asked of `textLayoutManager` rather than of the stack constant, + // because reading it is the one probe that answers which stack this + // view is on without moving it to the other one. + if let viewport = textLayoutManager?.textViewportLayoutController { + viewport.layoutViewport() + } else { + laidOutTo = min(laidOutTo, layoutManager?.firstUnlaidCharacterIndex() ?? -1) + } + } + + override func viewDidEndLiveResize() { + super.viewDidEndLiveResize() + + let visible = enclosingScrollView?.documentVisibleRect ?? .zero + + Trace.event( + "transcript.liveresize", + target: "JP.Transcript", + fields: [ + ("frames", .int(frames)), + ("width_changes", .int(widthChanges)), + ("container_changes", .int(containerChanges)), + ("container_width", .double(Double(textContainer?.size.width ?? 0))), + ("tracks_width", .bool(textContainer?.widthTracksTextView ?? false)), + ("draws", .int(draws)), + ("tallest_draw", .double(Double(tallestDraw))), + ("visible_height", .double(Double(visible.height))), + ("width_from", .double(Double(widthAtStart))), + ("width_to", .double(Double(frame.width))), + ("laid_out_to", .int(laidOutTo == Int.max ? -1 : laidOutTo)), + ("characters", .int(textStorage?.length ?? 0)), + ("visible_from_y", .double(Double(visible.minY))), + ("document_height", .double(Double(frame.height))), + ] + ) + } +} + +/// The transcript, drawn by one text view. +/// +/// The document is built here rather than handed in, so it is rebuilt only when +/// the conversation or the appearance changes — not on every layout pass, and +/// not on every frame of a window resize. +struct TranscriptTextView: NSViewRepresentable { + /// Which conversation is on screen, and the cheap half of deciding whether + /// the document has to be rebuilt. + let conversationID: String? + + /// What to draw, oldest turn first. + let turns: [ConversationTurn] + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> NSScrollView { + let textView = LiveWrappingTextView(usingTextLayoutManager: Self.usesTextKit2) + Self.configure(textView) + + textView.setAccessibilityIdentifier(AccessibilityID.Transcript.text) + + let scroll = NSScrollView() + scroll.documentView = textView + scroll.hasVerticalScroller = true + scroll.drawsBackground = false + scroll.setAccessibilityIdentifier(AccessibilityID.Transcript.scroll) + + context.coordinator.watchForLayoutManagerDowngrade(of: textView) + + return scroll + } + + /// Set a text view up to draw a transcript. + /// + /// Separate from ``makeNSView(context:)`` so it can be checked without a + /// SwiftUI host: several of these settings are the difference between a + /// transcript that behaves and one that looks right and does not. + static func configure(_ textView: NSTextView) { + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + // The SwiftUI background behind this view is the one the design calls + // for; AppKit's would paint over it. + textView.drawsBackground = false + textView.textContainerInset = NSSize(width: Self.margin, height: Self.margin) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + + // Width from the view, height unbounded: the container re-wraps as the + // window is resized and grows downwards as far as the document needs. + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: 0, height: CGFloat.greatestFiniteMagnitude) + // The document's own margin is `textContainerInset`; this would add five + // more points inside every line fragment. + textView.textContainer?.lineFragmentPadding = 0 + + // The cursor, and nothing else. + // + // This dictionary is what AppKit merges over a `.link` range as it draws, + // and it is also the whole mechanism behind the pointing hand: the default + // carries `.cursor` alongside a colour and an underline. Emptying it to + // keep the document's own colour takes the cursor with it, and a link that + // does not change the pointer does not read as a link. + textView.linkTextAttributes = [.cursor: NSCursor.pointingHand] + + layOutContiguously(textView) + } + + func updateNSView(_ scroll: NSScrollView, context: Context) { + guard let textView = scroll.documentView as? NSTextView else { return } + + let appearance = textView.effectiveAppearance + guard + context.coordinator.needsDocument( + for: conversationID, turnCount: turns.count, appearance: appearance) + else { return } + + // A colour is resolved into the document as it is built rather than each + // time it is drawn, so a window moved between appearances rebuilds. + let style = MarkdownStyle.reading(in: appearance) + let document = Trace.measuring( + "transcript.render", + target: Self.traceTarget, + fields: [("turn_count", .int(turns.count))] + ) { + TranscriptDocument.attributed(turns, style: style) + } + + textView.textStorage?.setAttributedString(document) + } + + /// What the transcript's events are attributed to. + private static let traceTarget = "JP.Transcript" + + /// The space between the text and the edges of the pane. + private static let margin: CGFloat = 24 + + /// Which layout stack the text view runs on. + /// + /// TextKit 1, bought deliberately and not cheaply. + /// + /// TextKit 2 lays out around the viewport and estimates the rest, which is + /// what a long document wants and is measurably faster here: the same ten + /// programmatic resizes cost 155 samples against this stack's 438 on a + /// 29-event conversation, and 355 against 412 on a 167-event one. TextKit 2 + /// scales with the document where this is flat, so the gap narrows as + /// conversations grow, but at these sizes it is behind. + /// + /// What contiguous layout buys is an exact document height, and so a scroll + /// bar that states the truth instead of an estimate that refines as it + /// scrolls and shifts the knob under the pointer. That was a stated goal, and + /// it is the reason for the trade. + /// + /// It is *not* what fixed re-wrapping during a window drag — that was the text + /// container not being told its new width, and it needed fixing on both + /// stacks. Switching here changes the scroll bar and the cost, nothing else. + private static let usesTextKit2 = false + + /// Ask a TextKit 1 view for an exact document height. + /// + /// Non-contiguous layout skips the ranges nobody is looking at, which is + /// faster to first paint and gives back an approximate total — the same + /// estimate, and so the same shifting scroll bar, that choosing this stack + /// was meant to avoid. Off, so the height is measured rather than guessed. + /// + /// Does nothing on TextKit 2, and asks in the order that keeps that true: + /// `textLayoutManager` reports which stack the view is on without changing + /// it, where reading `layoutManager` first would drag a TextKit 2 view down + /// to TextKit 1 permanently and silently. + private static func layOutContiguously(_ textView: NSTextView) { + guard textView.textLayoutManager == nil else { return } + + textView.layoutManager?.allowsNonContiguousLayout = false + } + + /// Per-view state that outlives a single layout pass. + @MainActor + final class Coordinator { + /// What the document currently in the text view was built from. + private var built: + (conversationID: String?, turnCount: Int, appearance: NSAppearance.Name)? + + /// Whether the document has to be rebuilt for this conversation and + /// appearance. + /// + /// The turn count stands in for the turns themselves, which would cost + /// a comparison of every message's text on a pass that happens on every + /// frame of a resize. It is enough because a window reads a conversation + /// once — turns written by a concurrent `jp query` are invisible until + /// the workspace is reopened — and because the view is rebuilt outright + /// when the conversation changes. + func needsDocument( + for conversationID: String?, turnCount: Int, appearance: NSAppearance + ) -> Bool { + let wanted = (conversationID, turnCount, appearance.name) + + guard let built else { + built = wanted + return true + } + + guard + built.conversationID == wanted.0, + built.turnCount == wanted.1, + built.appearance == wanted.2 + else { + self.built = wanted + return true + } + + return false + } + + /// Report a text view falling back to TextKit 1. + /// + /// The downgrade is silent, permanent for that view, and takes + /// viewport-driven layout with it — so a transcript that quietly became + /// slow at size would look like the layout work never helped rather + /// than like something switched it off. + func watchForLayoutManagerDowngrade(of textView: NSTextView) { + observer = NotificationCenter.default.addObserver( + forName: NSTextView.willSwitchToNSLayoutManagerNotification, + object: textView, + queue: .main + ) { _ in + Trace.event( + "transcript.textkit.downgrade", + target: "JP.Transcript", + level: .warn + ) + } + } + + /// The registration to undo when this coordinator goes away. + /// + /// `nonisolated(unsafe)` because `deinit` is not isolated and this is + /// not `Sendable`. Safe: it is written once while the view is being + /// made, on the main actor, and read once in `deinit` — which runs only + /// after the last reference to the coordinator is gone, so there is no + /// second access to race with. + private nonisolated(unsafe) var observer: (any NSObjectProtocol)? + + deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + } +} diff --git a/apps/macos/Sources/WindowButtons.swift b/apps/macos/Sources/WindowButtons.swift new file mode 100644 index 000000000..0fb1783c5 --- /dev/null +++ b/apps/macos/Sources/WindowButtons.swift @@ -0,0 +1,132 @@ +import AppKit +import SwiftUI + +/// Moves the close, minimize and zoom buttons down the window. +/// +/// macOS centres them 14 points below the top edge, which is the middle of a +/// standard title bar. A window with no title bar and a taller control in that +/// corner — a search field, say — leaves them sitting above that control's centre +/// rather than level with it. +/// +/// There is no supported way to ask for this. A title bar grows to fit a toolbar, +/// and a toolbar spans the whole window: it would put a strip of chrome above the +/// transcript, which is the thing having no title bar was for. So the buttons are +/// moved directly, and moved again whenever AppKit lays the title bar out afresh. +/// +/// Add it as a background of whatever the buttons should line up with: +/// +/// ```swift +/// SearchField(text: $query) +/// .background(WindowButtons.placed(leading: 18, centredOn: 24)) +/// ``` +enum WindowButtons { + /// A view that puts the window buttons `leading` points from the window's left + /// edge, centred `distance` points below its top. + /// + /// Draws nothing. Does nothing while `distance` is not a real measurement, so + /// a caller measuring the control can pass what it has before the first + /// layout without the buttons jumping to the top of the window. + static func placed(leading: CGFloat, centredOn distance: CGFloat) -> some View { + Mover(leading: leading, distance: distance) + } + + /// How far apart the buttons sit, centre to centre. + /// + /// What macOS itself uses, kept because the spacing is not what is being + /// changed here: measured off a running window, the three frames sit at 20 + /// point intervals. + static let spacing: CGFloat = 20 + + private struct Mover: NSViewRepresentable { + let leading: CGFloat + let distance: CGFloat + + func makeNSView(context: Context) -> NSView { + Probe() + } + + func updateNSView(_ view: NSView, context: Context) { + guard let probe = view as? Probe else { return } + probe.leading = leading + probe.distance = distance + probe.place() + } + } + + /// A view that does nothing but reposition its window's buttons. + private final class Probe: NSView { + /// Where the first button's frame belongs, from the window's left edge. + var leading: CGFloat = 0 + + /// Where the buttons' centre belongs, below the window's top edge. + var distance: CGFloat = 0 + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + observe() + place() + } + + /// The buttons a window puts in its top-left corner. + private static let kinds: [NSWindow.ButtonType] = [ + .closeButton, .miniaturizeButton, .zoomButton, + ] + + /// Put the buttons where ``distance`` says, if there are any to move. + /// + /// Each button's own height is what the centring is done against, rather + /// than a number written down here: they are 16 points tall today and that + /// is not this view's business. + func place() { + guard distance > 0, let window else { return } + + for (index, kind) in Self.kinds.enumerated() { + guard + let button = window.standardWindowButton(kind), + let container = button.superview + else { continue } + + // Absolute, not a shift: this runs again on every window layout, + // and nudging each button from wherever it currently is would walk + // them across the title bar. + let x = leading + CGFloat(index) * WindowButtons.spacing + + // The container is not flipped, so a larger `y` is higher up. + let y = container.bounds.height - distance - button.frame.height / 2 + guard x != button.frame.origin.x || y != button.frame.origin.y else { continue } + + button.setFrameOrigin(NSPoint(x: x, y: y)) + } + } + + /// Re-place the buttons whenever the window's own layout could have put + /// them back. + /// + /// A resize is the common one; entering full screen and leaving it again + /// rebuilds the title bar entirely. + private func observe() { + guard let window else { return } + + for name in [ + NSWindow.didResizeNotification, + NSWindow.didEnterFullScreenNotification, + NSWindow.didExitFullScreenNotification, + ] { + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidLayOut), + name: name, + object: window + ) + } + } + + @objc private func windowDidLayOut() { + place() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + } +} diff --git a/apps/macos/Sources/WorkspaceModel.swift b/apps/macos/Sources/WorkspaceModel.swift new file mode 100644 index 000000000..47c4a0d5d --- /dev/null +++ b/apps/macos/Sources/WorkspaceModel.swift @@ -0,0 +1,132 @@ +import Foundation + +/// What the conversation list has to show. +/// +/// One value rather than a set of separate properties, so a load result reaches +/// the view in a single mutation. Assigning several observed properties in a row +/// makes the list reload partway through its own update, which AppKit reports as +/// a reentrant `NSTableView` delegate call. +enum WorkspaceState: Equatable, Sendable { + /// A workspace is being read. + case loading + + /// The workspace's conversations, most recently active first. + case loaded([ConversationSummary]) + + /// There is nothing to list, and why. + case unavailable(title: String, detail: String) +} + +/// The open workspace and its conversation list. +/// +/// Loads once per opened workspace. Turns written by a concurrent `jp query` are +/// invisible until the workspace is reopened. +@MainActor +@Observable +final class WorkspaceModel { + /// What the conversation list has to show. + private(set) var state: WorkspaceState = .unavailable( + title: "No Workspace", + detail: "Choose File ▸ Open Workspace to browse a workspace." + ) + + /// The workspace being read, once one has been opened. + /// + /// The workspace this model was asked to open. + private(set) var path: String? + + /// The workspace that is actually open and ready to read. + /// + /// Distinct from ``path``, which is set the moment a workspace is *requested*. + /// A view that keyed a read on `path` would fire while the workspace was + /// still opening, find nothing, and never try again. + private(set) var openWorkspace: String? + + /// The workspace, held open for the life of the window. + private var session: WorkspaceSession? + + /// Open the workspace containing `path`, replacing whatever was open. + /// + /// `path` may be the workspace root or any directory inside it. + func open(_ path: String) async { + self.path = path + openWorkspace = nil + session = nil + state = .loading + + let timing = Trace.interval(Self.openSpan, target: Self.traceTarget) + let opened = await WorkspaceSession.open(path: path) + + // The window can close while a read is in flight. Its task is cancelled, + // but the read itself is not, so the result still arrives here. + guard !Task.isCancelled else { + timing.end([("cancelled", true)]) + return + } + + switch opened { + case .failure(let error): + state = .unavailable(title: "Could Not Open Workspace", detail: error.message) + timing.end([("failed", true)]) + + case .success(let session): + self.session = session + openWorkspace = path + + let conversations = await session.readConversations(spans: [Self.openSpan]) + + guard !Task.isCancelled else { + timing.end([("cancelled", true)]) + return + } + + state = Self.state(for: conversations) + timing.end([("conversation_count", .int((try? conversations.get())?.count ?? 0))]) + } + } + + /// What this model's events are attributed to. + private static let traceTarget = "JP.Workspace" + + /// The interval opening a workspace and listing it is recorded as. + private static let openSpan = "workspace.open" + + /// Read one conversation's turns from the open workspace. + /// + /// Reuses the open workspace rather than opening another: opening scans every + /// conversation directory in both storage roots, which is far too much work + /// to repeat every time somebody clicks a row. + /// `spans` names the intervals already open around this call, root first, so + /// the read is traced beneath the work that asked for it. + func events( + for conversationID: ConversationSummary.ID, + spans: [String] = [] + ) async -> Result<[ConversationTurn], WorkspaceError> { + guard let session else { + return .failure(WorkspaceError(message: "No workspace is open.")) + } + + return await session.readEvents(for: conversationID, spans: spans) + } + + /// The state a finished read leaves the list in. + private static func state( + for result: Result<[ConversationSummary], WorkspaceError> + ) -> WorkspaceState { + switch result { + case .success(let conversations) where conversations.isEmpty: + .unavailable( + title: "No Conversations", + detail: "This workspace has no conversations yet." + ) + // Already ordered most recently active first by the library, which is + // where that decision belongs: ordering timestamps needs them parsed, and + // every caller re-deriving it is how two views of one workspace end up + // disagreeing. + case .success(let conversations): + .loaded(conversations) + case .failure(let error): + .unavailable(title: "Could Not Read Workspace", detail: error.message) + } + } +} diff --git a/apps/macos/Sources/WorkspaceReader.swift b/apps/macos/Sources/WorkspaceReader.swift new file mode 100644 index 000000000..6b03bd266 --- /dev/null +++ b/apps/macos/Sources/WorkspaceReader.swift @@ -0,0 +1,243 @@ +import Foundation + +/// A conversation, as `jp_workspace_conversations` reports it. +/// +/// Hand-maintained to match `ConversationSummary` in the Rust `jp_plugin` crate. +/// Nothing checks that the two agree, so a field added there needs adding here +/// too; `ConversationSummaryTests` pins the payload this decodes from. +struct ConversationSummary: Decodable, Identifiable, Sendable, Equatable { + /// The conversation ID, as a decisecond timestamp in decimal. + let id: String + + /// The conversation title, absent until one has been generated or set. + let title: String? + + /// When the conversation was last activated, as RFC 3339 text. + /// + /// Deliberately unparsed. The Rust side emits a fractional-seconds part + /// whenever the stored timestamp has one, and `JSONDecoder`'s `.iso8601` + /// strategy rejects fractional seconds, so a `Date` here would decode the + /// whole-second case and fail on every real workspace. ``ConversationDate`` + /// is where the parsing happens, for the code that displays it. + let lastActivatedAt: String + + /// When the conversation was pinned, as RFC 3339 text, absent if it is not + /// pinned. + /// + /// Unparsed for the same reason as ``lastActivatedAt``, and nothing shows + /// the instant itself: what the sidebar needs is ``isPinned``. + let pinnedAt: String? + + /// How many events the conversation holds. + let eventsCount: Int + + /// Whether the conversation is pinned. + var isPinned: Bool { + pinnedAt != nil + } + + enum CodingKeys: String, CodingKey { + case id + case title + case lastActivatedAt = "last_activated_at" + case pinnedAt = "pinned_at" + case eventsCount = "events_count" + } +} + +/// One piece of work the library timed inside a single call. +/// +/// Hand-maintained to match `Span` in the Rust `jp_ffi` crate. Nothing checks +/// that the two agree; `WorkspaceReaderTests` decodes the exact payload that +/// crate's own tests pin. +struct LibrarySpan: Decodable, Sendable, Equatable { + /// What the work is called, written as the trace event's message. + let name: String + + /// How long it took, in milliseconds. + let durationMS: Double + + enum CodingKeys: String, CodingKey { + case name + case durationMS = "duration_ms" + } +} + +/// A failure reported by the Rust library, or by decoding its output. +struct WorkspaceError: LocalizedError, Sendable, Equatable { + let message: String + + var errorDescription: String? { message } +} + +/// An open JP workspace. +/// +/// Noncopyable, so the compiler enforces what the C contract requires: exactly +/// one owner of the handle, and exactly one `jp_workspace_close`. Copying this +/// would give two owners and a double free, which is a compile error rather than +/// a crash. +/// +/// Reading takes locks and touches the filesystem. Call it off the main thread or +/// the UI stalls behind a slow read. +struct WorkspaceReader: ~Copyable { + private let handle: OpaquePointer + + /// Open the workspace containing `path`, which may be the workspace root or + /// any directory inside it. + /// + /// Opening writes to disk: it creates the user-local conversation store if + /// missing and moves corrupt conversations aside, as `jp` does on startup. + init(path: String) throws(WorkspaceError) { + // Swift materializes a NUL-terminated buffer for the duration of the + // call, which is all `jp_workspace_open` requires of the pointer. + guard let handle = jp_workspace_open(path) else { + throw Self.lastError() + } + self.handle = handle + } + + deinit { + jp_workspace_close(handle) + } + + /// What a read is attributed to on the timeline. + /// + /// A target of its own, so time spent below this boundary reads as the + /// library's rather than the app's. + static let traceTarget = "JP.FFI" + + /// The interval a conversation-list read is recorded as. + static let conversationsSpan = "workspace.conversations" + + /// The interval an event read is recorded as. + static let eventsSpan = "workspace.events" + + /// Every conversation in the workspace, most recently active first. + /// + /// `spans` names the intervals already open around this call, root first. + /// The library's own timings are recorded beneath them, so a reader sees + /// where inside the app's work the library's time went. + borrowing func conversations( + spans: [String] = [] + ) throws(WorkspaceError) -> [ConversationSummary] { + let timing = Trace.interval( + Self.conversationsSpan, target: Self.traceTarget, spans: spans) + defer { timing.end() } + + // Asked for only while something is listening. Unrecorded, the library + // allocates no timings string and nothing here has one to release. + var timings: UnsafeMutablePointer? + let raw = + Trace.isRecording + ? jp_workspace_conversations(handle, &timings) + : jp_workspace_conversations(handle, nil) + + Self.record(timings, under: spans + [Self.conversationsSpan]) + + guard let raw else { + throw Self.lastError() + } + + let json = Self.take(raw) + do { + return try JSONDecoder().decode([ConversationSummary].self, from: json) + } catch { + throw WorkspaceError(message: "could not decode the conversation list: \(error)") + } + } + + /// Every event in a conversation, oldest first. + /// + /// `conversationID` is the `id` of a summary from ``conversations(spans:)``. + /// `spans` names the intervals already open around this call, root first. + borrowing func events( + for conversationID: String, + spans: [String] = [] + ) throws(WorkspaceError) -> [ConversationTurn] { + let timing = Trace.interval(Self.eventsSpan, target: Self.traceTarget, spans: spans) + defer { timing.end() } + + var timings: UnsafeMutablePointer? + let raw = + Trace.isRecording + ? jp_workspace_events(handle, conversationID, &timings) + : jp_workspace_events(handle, conversationID, nil) + + Self.record(timings, under: spans + [Self.eventsSpan]) + + guard let raw else { + throw Self.lastError() + } + + do { + return try JSONDecoder().decode([ConversationTurn].self, from: Self.take(raw)) + } catch { + throw WorkspaceError(message: "could not decode the event list: \(error)") + } + } + + /// Write what the library timed inside one call, nested under `enclosing`. + /// + /// `raw` is null when no timings were asked for, and when the library could + /// not build them. + private static func record( + _ raw: UnsafeMutablePointer?, + under enclosing: [String] + ) { + guard let raw else { return } + + for line in timingLines(take(raw), under: enclosing, at: Trace.timestamp(Date())) { + Trace.write(line) + } + } + + /// The trace lines the library's timings become, nested under `enclosing`. + /// + /// Built rather than written straight out, so a test can pin them: nesting + /// is the whole point of these events, and one written with an empty span + /// stack looks like any other line in the file. + /// + /// Every span of one call carries `timestamp`, because durations are all the + /// library reports. There is no second clock to place them on, and the order + /// they are written in is the order they ran. + /// + /// A payload that will not decode produces no lines. Instrumentation nobody + /// can read is not a reason to fail the read it was measuring. + static func timingLines( + _ json: Data, + under enclosing: [String], + at timestamp: String + ) -> [String] { + guard let spans = try? JSONDecoder().decode([LibrarySpan].self, from: json) else { + return [] + } + + return spans.compactMap { span in + Trace.line( + timestamp: timestamp, + level: .info, + target: traceTarget, + message: span.name, + fields: [("duration_ms", .double(span.durationMS))], + spans: enclosing + ) + } + } + + /// The library's message for the most recent failure on this thread. + private static func lastError() -> WorkspaceError { + guard let raw = jp_last_error() else { + return WorkspaceError(message: "the library reported a failure without a message") + } + return WorkspaceError(message: String(decoding: take(raw), as: UTF8.self)) + } + + /// Copy a string the library allocated, releasing the original. + /// + /// Rust frees what Rust allocates, so the bytes are copied out and the + /// pointer handed straight back. + private static func take(_ raw: UnsafeMutablePointer) -> Data { + defer { jp_string_free(raw) } + return Data(bytes: raw, count: strlen(raw)) + } +} diff --git a/apps/macos/Sources/WorkspaceSession.swift b/apps/macos/Sources/WorkspaceSession.swift new file mode 100644 index 000000000..d2041a0a9 --- /dev/null +++ b/apps/macos/Sources/WorkspaceSession.swift @@ -0,0 +1,76 @@ +import Foundation + +/// A workspace held open, off the main actor. +/// +/// Opening a workspace scans every conversation directory in both storage roots, +/// so it happens once per workspace rather than once per read. Reads are +/// serialized by the actor, which also keeps the reader — a noncopyable value +/// that cannot cross an isolation boundary — in one place. +actor WorkspaceSession { + private let reader: WorkspaceReader + + /// Open the workspace containing `path`. + /// + /// `path` may be the workspace root or any directory inside it. + init(path: String) throws(WorkspaceError) { + reader = try WorkspaceReader(path: path) + } + + /// Every conversation in the workspace. + /// + /// `spans` names the intervals already open around this call, root first, so + /// the read is traced beneath the work that asked for it. + func conversations(spans: [String] = []) throws(WorkspaceError) -> [ConversationSummary] { + try reader.conversations(spans: spans) + } + + /// One conversation's events, oldest first. + /// + /// `spans` names the intervals already open around this call, root first. + func events( + for conversationID: String, + spans: [String] = [] + ) throws(WorkspaceError) -> [ConversationTurn] { + try reader.events(for: conversationID, spans: spans) + } +} + +extension WorkspaceSession { + /// Open the workspace at `path`, off the main actor. + static func open(path: String) async -> Result { + let opened = Task.detached { () -> Result in + do throws(WorkspaceError) { + return .success(try WorkspaceSession(path: path)) + } catch { + return .failure(error) + } + } + + return await opened.value + } + + /// Read every conversation, returning the failure rather than throwing so a + /// caller can put it on screen. + func readConversations( + spans: [String] = [] + ) async -> Result<[ConversationSummary], WorkspaceError> { + do throws(WorkspaceError) { + return .success(try conversations(spans: spans)) + } catch { + return .failure(error) + } + } + + /// Read one conversation's events, returning the failure rather than + /// throwing. + func readEvents( + for conversationID: String, + spans: [String] = [] + ) async -> Result<[ConversationTurn], WorkspaceError> { + do throws(WorkspaceError) { + return .success(try events(for: conversationID, spans: spans)) + } catch { + return .failure(error) + } + } +} diff --git a/apps/macos/Sources/WorkspaceWindow.swift b/apps/macos/Sources/WorkspaceWindow.swift new file mode 100644 index 000000000..cd8ba7098 --- /dev/null +++ b/apps/macos/Sources/WorkspaceWindow.swift @@ -0,0 +1,527 @@ +import SwiftUI + +/// One workspace, in one window. +/// +/// Owns its model, so each window reads its own workspace and windows can be +/// tabbed together or pulled apart without sharing state. +struct WorkspaceWindow: View { + /// The workspace this window shows, restored when the window reopens. + /// + /// Per window rather than per app: two windows on two workspaces is the whole + /// point of having windows. + @SceneStorage("workspacePath") private var workspacePath: String? + + /// Whether this window's directory chooser is on screen. + @State private var isChoosingWorkspace = false + + @State private var model = WorkspaceModel() + @Environment(RecentWorkspaces.self) private var recents + + /// The selected conversation. + /// + /// Plain state, mirrored to ``storedSelection`` rather than bound directly to + /// it: a `List` writes its selection binding while it is handling the click, + /// and scene storage persists on write, which puts a view update inside the + /// table view's own update. + @State private var selection: String? + + /// The selected conversation as the window last had it, restored on reopen. + @SceneStorage("selectedConversation") private var storedSelection: String? + + /// What the filter box holds. + /// + /// Not persisted. A filter is a way of looking at the list right now, and a + /// window that reopened onto a list mysteriously missing most of its rows + /// would be a bug report. + @State private var query = "" + + /// Stable identity for this window, for as long as it exists. + /// + /// Only the menu actions use it, and only so a republished + /// ``WorkspaceActions`` from this window compares equal to the last one. + @State private var windowID = UUID() + + /// Whether the sidebar is showing. + /// + /// Written only when View ▸ Hide Sidebar is chosen, so it is bound straight to + /// scene storage: a window comes back with the sidebar it was closed with. + @SceneStorage("sidebarVisible") private var isSidebarVisible = true + + /// How wide the sidebar is. + /// + /// Plain state, mirrored to ``storedSidebarWidth`` when a drag ends rather than + /// bound to it: scene storage persists on every write, and a drag writes on + /// every frame it is dragged through. + @State private var sidebarWidth = Self.defaultSidebarWidth + + /// The sidebar's width as the window last had it, restored on reopen. + @SceneStorage("sidebarWidth") private var storedSidebarWidth: Double? + + /// When the conversations on screen were read. + /// + /// What the rows date themselves against. Fixed at the moment of the read + /// rather than taken fresh per render, because a render happens on every frame + /// of a divider drag and a clock reading that changes each time would make the + /// list unequal to itself and undo the skipping that keeps the drag smooth. + /// + /// The cost is that "21 minutes ago" is 21 minutes after the workspace was + /// opened, not after now. + @State private var listingReadAt = Date() + + /// How tall the search field turned out to be. + /// + /// Measured because it follows the field's font rather than a number this view + /// chooses, and the window buttons are centred against it. Zero until the first + /// layout, which leaves the buttons where macOS put them. + @State private var searchFieldHeight: CGFloat = 0 + + /// Whether the conversation list is scrolled away from its top. + /// + /// Only decides whether a line is drawn above the first row, and only changes + /// when the list leaves or returns to the top rather than as it scrolls. + @State private var isListScrolled = false + + /// The width the sidebar was at when the current drag started. + /// + /// A drag reports its translation from where it began, so resizing needs the + /// width it began from. Nil between drags. + @State private var dragStartWidth: Double? + + @Environment(\.openWindow) private var openWindow + + var body: some View { + // The whole body, because what it costs is what a window costs to + // re-render, and the rows underneath are not instrumented: a transcript + // realizes thousands of them, and a line per row would be a trace nobody + // can read of a run nobody can time. + Trace.measuring("WorkspaceWindow.body", target: Self.traceTarget) { + content + } + } + + /// What the window shows, timed by ``body``. + private var content: some View { + let listing = self.listing + + return HStack(spacing: 0) { + if isSidebarVisible { + sidebar(listing) + .frame(width: sidebarWidth) + + splitDivider + // Up into the title bar's strip, which the sidebar beside it + // already fills. Without this the line starts below the strip + // and the window's own title bar colour shows through above it. + .ignoresSafeArea(.container, edges: .top) + // Above both panes, for hit testing as much as for drawing. + // + // The grab strip is wider than the line and hangs over the pane + // on either side. Later siblings in a stack are in front, so + // without this the transcript covers the half of the strip on + // its side: approaching the divider from the sidebar worked and + // approaching it from the transcript did nothing at all — + // neither the cursor nor the drag. + .zIndex(1) + } + + ConversationHistoryView(model: model, conversationID: selection) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + // What keeps the window on screen at all. A window is sized from its + // content, and content that states only maxima has an ideal size of + // nothing: the window collapses, and a collapsed window is absent from the + // window server's list rather than merely small. Held here rather than left + // to the scene, because the scene's `defaultSize` applies to a window + // opened fresh and not to one restored into a saved frame. + .frame(minWidth: Self.minimumWindowWidth, minHeight: Self.minimumWindowHeight) + // Carried but not displayed: the window has no title bar to show it in. + // It is still what the Window menu lists the window under, and what an + // external driver addresses it by. + .navigationTitle(title) + // A driven copy of the app can acquire a Space of its own, and a window + // one Space away is absent from the accessibility tree rather than merely + // off screen. Nothing outside a debug run. + .background(DebugSpaces.joinEverySpace()) + // Controls that tint pick this up, which is most of what makes the + // window look like one app rather than a themed list beside a stock one. + .tint(Theme.accent.color) + .onAppear { Trace.endLaunch() } + .task(id: workspacePath) { + // Restored before the list exists. Setting it afterwards would + // change the list's selection during the list's own update. + selection = storedSelection + sidebarWidth = storedSidebarWidth ?? Self.defaultSidebarWidth + await load() + listingReadAt = Date() + } + .onChange(of: selection) { _, new in storedSelection = new } + // Offers the File menu this window, so ⌘O and Open Recent act on whichever + // window is in front rather than on the app as a whole. + .focusedSceneValue( + \.workspaceActions, + WorkspaceActions( + windowID: windowID, + hasSelection: selection != nil, + isSidebarVisible: isSidebarVisible, + choose: { isChoosingWorkspace = true }, + open: { show($0) }, + copyLinks: { copyLinks(for: selectedIDs, among: listing?.all ?? []) }, + toggleSidebar: { isSidebarVisible.toggle() } + ) + ) + .fileImporter(isPresented: $isChoosingWorkspace, allowedContentTypes: [.folder]) { + result in + guard case .success(let url) = result else { return } + show(url) + } + } + + /// The line between the sidebar and the transcript, and the handle that + /// resizes them. + /// + /// Drawn by the window rather than by a `NavigationSplitView`, which was what + /// held these two panes before. `NSSplitView` draws a translucent divider over + /// whatever is behind it and offers no way to change either the colour or the + /// width, so the line came out two pixels of two different greys that shifted + /// with the content underneath. This one is the colour it is told to be. + /// + /// The grab area is wider than the line, because two points is not something a + /// person can reliably hit. + private var splitDivider: some View { + Rectangle() + .fill(Theme.paneDivider.color) + .frame(width: Self.dividerWidth) + .overlay { + Rectangle() + .fill(.clear) + .contentShape(.rect) + .frame(width: Self.dividerGrabWidth) + // SwiftUI's own cursor modifier, and the only thing that works + // here. A hosted `NSView` with cursor rects and a hover + // callback pushing `NSCursor` both lose the cursor back to an + // arrow whenever SwiftUI updates the view — they are competing + // with the framework for ownership of it rather than asking. + // + // `columnResize` is the pointer for a vertical boundary that + // moves left and right, which is what this is. + .pointerStyle(.columnResize) + .gesture(resize) + // A clear shape is decorative as far as SwiftUI is concerned + // and is left out of the tree entirely, identifier and all. + // This is what makes it an element, so a driver can find the + // strip and drag it. + .accessibilityElement() + .accessibilityLabel("Resize sidebar") + .accessibilityIdentifier(AccessibilityID.paneDivider) + } + } + + /// Widen or narrow the sidebar by dragging the divider. + private var resize: some Gesture { + DragGesture(coordinateSpace: .global) + .onChanged { drag in + let start = dragStartWidth ?? sidebarWidth + dragStartWidth = start + sidebarWidth = min( + max(start + drag.translation.width, Self.sidebarWidths.lowerBound), + Self.sidebarWidths.upperBound + ) + } + .onEnded { _ in + dragStartWidth = nil + storedSidebarWidth = sidebarWidth + } + } + + /// What the menu commands act on. + /// + /// The list's own commands are handed a set by + /// `contextMenu(forSelectionType:)`, and this is the same thing for the menu + /// bar, which has no such argument to be given. + private var selectedIDs: Set { + selection.map { [$0] } ?? [] + } + + /// Open each named conversation in a window of its own. + private func openWindows( + for ids: Set, among all: [ConversationSummary] + ) { + for conversation in all where ids.contains(conversation.id) { + openWindow(id: ConversationWindow.sceneID, value: reference(to: conversation)) + } + } + + /// Put each named conversation's URI on the pasteboard, one per line. + private func copyLinks( + for ids: Set, among all: [ConversationSummary] + ) { + let links = + all + .filter { ids.contains($0.id) } + .map { reference(to: $0).uri } + .joined(separator: "\n") + + guard !links.isEmpty else { return } + + let pasteboard = DebugState.pasteboard + pasteboard.clearContents() + pasteboard.setString(links, forType: .string) + } + + /// Show `url`'s workspace in this window. + private func show(_ url: URL) { + selection = nil + // Canonicalized so a path chosen through the panel and the same path from + // the recents menu are one value, and reselecting the open workspace does + // not reload it. + workspacePath = url.canonicalized.path(percentEncoded: false) + } + + /// The conversation list as the window is currently showing it. + private struct Listing { + /// Everything the workspace holds. + let all: [ConversationSummary] + + /// What the filter leaves of it, in the order the sidebar shows them. + let matches: [ConversationSummary] + } + + /// The listing, once a workspace has been read. + /// + /// Derived once per render and handed to both the sidebar and the menu + /// actions. Filtering walks every conversation and this workspace holds a + /// thousand, so working it out twice is a thousand extra comparisons on every + /// keystroke. + private var listing: Listing? { + guard case .loaded(let all) = model.state else { return nil } + + return Listing( + all: all, + matches: ConversationOrder.pinnedFirst( + ConversationFilter.matches(all, query: query)) + ) + } + + /// The list, or why there is no list. + /// + /// The empty states replace the list rather than covering it, so no table + /// view exists to be updated while there is nothing to show. + @ViewBuilder + private func sidebar(_ listing: Listing?) -> some View { + switch model.state { + case .loading: + ProgressView() + .controlSize(.small) + .accessibilityLabel("Loading conversations") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier(AccessibilityID.Sidebar.loadingState) + + case .loaded: + if let listing { + VStack(spacing: 0) { + SearchField(text: $query) + // Measured rather than assumed: the field's height follows + // its font, and the window buttons are lined up against it. + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + searchFieldHeight = height + } + // Moves the window buttons down to the field's own centre. + // They sit 14 points down by default, which is the middle + // of a title bar this window does not have. + .background( + WindowButtons.placed( + leading: Self.windowButtonsLeading, + centredOn: Self.searchFieldPadding + searchFieldHeight / 2 + ) + ) + // Room for the window buttons, which the search field sits + // beside rather than below. + .padding(.leading, Self.windowButtonsWidth) + // The same on the other three sides, so the field sits in + // the corner of the window rather than against its edge. + .padding([.top, .trailing, .bottom], Self.searchFieldPadding) + + matchList(listing) + // A line above the first row only once the list has + // scrolled away from the top, which is what separates the + // search field from rows passing under it. At rest there is + // nothing to separate, and a line there reads as a border + // the design does not have. + // + // An overlay rather than a row in the stack, so its + // appearing does not shift the list down by a point. + .overlay(alignment: .top) { + if isListScrolled { + Rectangle() + .fill(Theme.rowSeparator.color) + .frame(height: 1) + } + } + } + .background(Theme.sidebarBackground.color) + // Takes the title bar's strip back from the system, which is what + // puts the search field level with the window buttons instead of + // under them. Safe because the search field is the topmost thing + // in the sidebar and it holds its own padding; nothing scrolls + // under the buttons. + .ignoresSafeArea(.container, edges: .top) + } + + case .unavailable(let title, let detail): + ContentUnavailableView(title, systemImage: "bubble.left", description: Text(detail)) + .accessibilityIdentifier(AccessibilityID.Sidebar.unavailableState) + } + } + + /// The conversations matching the filter, or a note that none do. + /// + /// The two replace each other rather than one covering the other, for the same + /// reason as the outer empty states: no table view should exist while there is + /// nothing for it to show. + @ViewBuilder + private func matchList(_ listing: Listing) -> some View { + if listing.matches.isEmpty { + ContentUnavailableView.search(text: query) + .accessibilityIdentifier(AccessibilityID.Sidebar.noMatchesState) + } else { + // `.equatable()` rather than left to SwiftUI's own judgement: this is + // the view whose body must be skipped while the divider is dragged, and + // the wrapper is what makes the comparison happen for certain. + ConversationList( + matches: listing.matches, + separatorless: ConversationOrder.rowsWithoutSeparator( + in: listing.matches, selecting: selection), + now: listingReadAt, + selectedID: selection, + selection: $selection, + reference: { reference(to: $0) }, + openWindows: { openWindows(for: $0, among: listing.all) }, + copyLinks: { copyLinks(for: $0, among: listing.all) }, + scrolledAwayFromTop: { isListScrolled = $0 } + ) + .equatable() + } + } + + private var title: String { + workspacePath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "JP" + } + + private func reference(to conversation: ConversationSummary) -> ConversationRef { + ConversationRef( + workspacePath: workspacePath ?? "", + conversationID: conversation.id, + title: conversation.title + ) + } + + /// How wide the sidebar is in a window that has never been resized. + private static let defaultSidebarWidth: Double = 280 + + /// How narrow and how wide the sidebar can be dragged. + /// + /// The lower bound is where a row's title stops being readable; the upper is + /// where the sidebar starts crowding the transcript. + private static let sidebarWidths: ClosedRange = 220...480 + + /// The line between the panes. + /// + /// One point, which is two pixels on a retina display and matches Bear. + private static let dividerWidth: CGFloat = 1 + + /// The narrowest the window can be. + /// + /// The narrowest sidebar, its divider, and enough left over for a line of + /// transcript to be worth reading. + private static let minimumWindowWidth: CGFloat = + CGFloat(sidebarWidths.lowerBound) + dividerWidth + 400 + + /// The shortest the window can be: a handful of conversation rows. + private static let minimumWindowHeight: CGFloat = 400 + + /// How much space surrounds the search field on the three sides the window + /// buttons do not occupy. + /// + /// Even padding and buttons level with the field cannot both be had from + /// layout alone: the buttons sit 14 points down, so an evenly padded field of + /// height `H` centres at `padding + H/2` and matching 14 forces the field + /// smaller the more padding it has. The padding is kept even and the buttons + /// are moved to meet it; see ``WindowButtons``. + private static let searchFieldPadding: CGFloat = 8 + + /// How wide a strip around the divider responds to a drag. + private static let dividerGrabWidth: CGFloat = 10 + + /// Where the first window button's frame is put, from the window's left edge. + /// + /// macOS puts it six points in, which reads as cramped against a window with + /// no title bar. Measured off Bear: the visible circle sits twenty points in, + /// and the frame is two points wider than the circle on each side. + private static let windowButtonsLeading: CGFloat = 18 + + /// How much of the sidebar's top-left corner the window buttons occupy. + /// + /// Three frames at ``WindowButtons/spacing``, from + /// ``windowButtonsLeading``, and then the gap before the search field starts. + private static let windowButtonsWidth: CGFloat = + windowButtonsLeading + 2 * WindowButtons.spacing + 16 + 6 + + /// What this window's events are attributed to. + private static let traceTarget = "JP.Workspace" + + private func load() async { + guard + let path = Self.chooseWorkspace( + stored: workspacePath, + mostRecent: recents.urls.first, + environment: ProcessInfo.processInfo.environment + ) + else { return } + + // Writing this back changes `task(id:)`, which cancels this run and starts + // another with the chosen path already stored. The second pass chooses the + // same path and writes nothing. + if workspacePath != path { + workspacePath = path + } + + // Recording the workspace here rather than only where it is chosen keeps + // a window restored at launch in the recents list too. + recents.note(URL(fileURLWithPath: path)) + await model.open(path) + } + + /// The workspace a window should show, in order of precedence. + /// + /// 1. `JP_WORKSPACE`, an instruction given at launch. + /// 2. The path this window stored, so a reopened window comes back where it + /// was and two windows can sit on two workspaces. + /// 3. The most recently opened workspace, which a new window overwhelmingly + /// wants and which saves choosing it again. + /// + /// The environment comes first because it is the only one of the three a + /// caller sets deliberately, per launch. Below the stored path it would be + /// read exactly once in a window's life and silently ignored on every later + /// launch, which makes `just run-app ` a no-op after the first run + /// and leaves a harness unable to point an instance anywhere. + /// `nonisolated` because it reads none of the view's state. A `View` is + /// main-actor isolated and its statics inherit that, which this does not need. + nonisolated static func chooseWorkspace( + stored: String?, + mostRecent: URL?, + environment: [String: String] + ) -> String? { + if let named = environment["JP_WORKSPACE"], !named.isEmpty { + return named + } + + if let stored, !stored.isEmpty { + return stored + } + + return mostRecent?.path(percentEncoded: false) + } + +} diff --git a/apps/macos/Tests/AccessibilityIDTests.swift b/apps/macos/Tests/AccessibilityIDTests.swift new file mode 100644 index 000000000..8912fe692 --- /dev/null +++ b/apps/macos/Tests/AccessibilityIDTests.swift @@ -0,0 +1,61 @@ +import Testing + +@testable import JP + +/// Pins the identifier strings themselves. +/// +/// An external driver looks elements up by these names, so they are a contract +/// with something outside this repository: changing one is a breaking change, +/// and these tests are what makes that visible in a diff. +@Suite("AccessibilityID") +struct AccessibilityIDTests { + @Test("names the sidebar's elements") + func namesTheSidebar() { + #expect(AccessibilityID.Sidebar.list == "sidebar.list") + #expect(AccessibilityID.Sidebar.filter == "sidebar.filter") + #expect(AccessibilityID.Sidebar.filterClear == "sidebar.filter.clear") + #expect(AccessibilityID.Sidebar.loadingState == "sidebar.state.loading") + #expect(AccessibilityID.Sidebar.noMatchesState == "sidebar.state.nomatches") + #expect(AccessibilityID.Sidebar.unavailableState == "sidebar.state.unavailable") + #expect(AccessibilityID.Sidebar.row("17251488000") == "sidebar.row.17251488000") + } + + @Test("names the transcript's elements") + func namesTheTranscript() { + #expect(AccessibilityID.Transcript.scroll == "transcript.scroll") + #expect(AccessibilityID.Transcript.loadingState == "transcript.state.loading") + #expect(AccessibilityID.Transcript.unavailableState == "transcript.state.unavailable") + #expect(AccessibilityID.Transcript.text == "transcript.text") + } + + /// The grab strip between the panes, which a driver reaches by dragging + /// because the sidebar's width cannot be written through the tree. + @Test("names the strip that resizes the sidebar") + func namesThePaneDivider() { + #expect(AccessibilityID.paneDivider == "window.divider") + } + + /// A driver that found a row before a title was generated has to still find + /// it afterwards, so the row's name comes from the conversation ID and + /// nothing else. + @Test("names a row the same before and after it is titled") + func survivesARetitle() { + let untitled = ConversationSummary( + id: "17251488000", + title: nil, + lastActivatedAt: "2026-08-01T09:00:00Z", + pinnedAt: nil, + eventsCount: 4 + ) + let titled = ConversationSummary( + id: "17251488000", + title: "Reading list", + lastActivatedAt: "2026-08-01T09:00:00Z", + pinnedAt: nil, + eventsCount: 4 + ) + + #expect( + AccessibilityID.Sidebar.row(untitled.id) == AccessibilityID.Sidebar.row(titled.id)) + } +} diff --git a/apps/macos/Tests/ClipboardPolicyTests.swift b/apps/macos/Tests/ClipboardPolicyTests.swift new file mode 100644 index 000000000..5fd405a9e --- /dev/null +++ b/apps/macos/Tests/ClipboardPolicyTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing + +/// The UI suite must never touch the *system* pasteboard. +/// +/// There is one of those and it belongs to whoever is at the keyboard. A test +/// that triggers a copy into it destroys what they had, and saving and +/// restoring around the test is not a fix: a pasteboard item can be a promise +/// its owner fulfils lazily, so a restore puts back a degraded copy and an +/// early exit puts back nothing at all. +/// +/// A *named* pasteboard has none of that problem, so the UI tests use one: a +/// debug build reads `JP_DEBUG_PASTEBOARD` and copies there instead (see +/// ``DebugState/pasteboard``), and `WorkspaceFixture.copiedText()` reads it +/// back. Copy Link is covered end to end without a clipboard being lost. +/// +/// What this forbids is therefore narrow and exact: the spellings that mean +/// "the one everybody shares". It is a source scan rather than a rule in a +/// document because a rule in a document is not enforced by anything. +@Suite("ClipboardPolicy") +struct ClipboardPolicyTests { + /// The spellings that reach the system pasteboard. + /// + /// `NSPasteboard(name: .general)` is the same object as + /// `NSPasteboard.general`, so naming it counts too. + static let forbidden = [ + "NSPasteboard.general", + "UIPasteboard.general", + "Name.general", + "name: .general", + ] + + @Test("no UI test reaches for the system pasteboard") + func uiTestsDoNotTouchThePasteboard() throws { + let sources = try Self.uiTestSources() + + // A scan over nothing passes for the wrong reason, and would keep + // passing if the directory were renamed. + #expect(sources.count >= 3, "expected to find the UI test sources to scan") + + for source in sources { + let text = try String(contentsOf: source, encoding: .utf8) + for symbol in Self.forbidden where text.contains(symbol) { + Issue.record( + """ + \(source.lastPathComponent) reaches the system pasteboard through \ + `\(symbol)`. Copy through the fixture's own pasteboard instead: the app \ + writes to the one `JP_DEBUG_PASTEBOARD` names, and \ + `WorkspaceFixture.copiedText()` reads it back. + """ + ) + } + } + } + + /// Every Swift file in `apps/macos/UITests`. + /// + /// Located from this file's compile-time path. The app is not sandboxed and + /// these tests are hosted by it, so the checkout is readable from here. + static func uiTestSources() throws -> [URL] { + // .../apps/macos/Tests/ClipboardPolicyTests.swift + let directory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("UITests") + + return try FileManager.default + .contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "swift" } + } +} diff --git a/apps/macos/Tests/ConversationDateTests.swift b/apps/macos/Tests/ConversationDateTests.swift new file mode 100644 index 000000000..94f2e248c --- /dev/null +++ b/apps/macos/Tests/ConversationDateTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing + +@testable import JP + +@Suite("ConversationDate") +struct ConversationDateTests { + /// UTC, so a fixed timestamp lands on the same calendar day wherever the test + /// runs. A machine in Auckland would otherwise read "12:30Z on 2 September" as + /// a different day from one in Los Angeles, and the same-day branch is the + /// whole point of half these tests. + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + guard let utc = TimeZone(identifier: "UTC") else { return calendar } + calendar.timeZone = utc + return calendar + }() + + /// Fixed, because a formatted date's word order and month name are the + /// locale's: "2 Sept" in one place, "Sep 2" in another. + private static let locale = Locale(identifier: "en_GB") + + /// The instant `text` names, or a failure naming what would not parse. + private func date(_ text: String) throws -> Date { + try #require(ConversationDate.parse(text), "\(text) did not parse") + } + + private func label(_ text: String, now: String) throws -> String { + ConversationDate.label( + for: try date(text), + now: try date(now), + calendar: Self.calendar, + locale: Self.locale + ) + } + + @Test("parses a whole-second timestamp") + func parsesWholeSeconds() throws { + #expect(try date("2024-09-02T12:30:00Z").timeIntervalSince1970 == 1_725_280_200) + } + + /// Any conversation JP created from a wall clock carries sub-second + /// precision, so this is the shape the app sees in practice. + @Test("parses a timestamp with fractional seconds") + func parsesFractionalSeconds() throws { + let parsed = try date("2024-09-02T12:30:00.500000Z") + + #expect(parsed.timeIntervalSince1970 == 1_725_280_200.5) + } + + @Test("reports a timestamp it cannot read") + func rejectsNonsense() { + #expect(ConversationDate.parse("") == nil) + #expect(ConversationDate.parse("yesterday") == nil) + #expect(ConversationDate.parse("2024-09-02") == nil) + } + + @Test("says how long ago a conversation active today was") + func minutesAgoToday() throws { + #expect( + try label("2026-08-03T09:39:00Z", now: "2026-08-03T10:00:00Z") == "21 minutes ago") + #expect( + try label("2026-08-03T09:59:00Z", now: "2026-08-03T10:00:00Z") == "1 minute ago") + #expect(try label("2026-08-03T08:00:00Z", now: "2026-08-03T10:00:00Z") == "2 hours ago") + #expect(try label("2026-08-03T09:00:00Z", now: "2026-08-03T10:00:00Z") == "1 hour ago") + } + + /// Under a minute has no useful number to show, and a clock adjustment can + /// put a stored timestamp slightly in the future. + @Test("says just now for anything under a minute, in either direction") + func justNow() throws { + #expect(try label("2026-08-03T09:59:30Z", now: "2026-08-03T10:00:00Z") == "just now") + #expect(try label("2026-08-03T10:00:30Z", now: "2026-08-03T10:00:00Z") == "just now") + } + + /// Yesterday is a different day even when it is only minutes ago, because a + /// row saying "40 minutes ago" for something dated yesterday reads as wrong. + @Test("dates a conversation from another day rather than timing it") + func earlierThisYear() throws { + #expect(try label("2026-08-02T23:40:00Z", now: "2026-08-03T00:20:00Z") == "2 Aug") + #expect(try label("2026-05-13T09:00:00Z", now: "2026-08-03T10:00:00Z") == "13 May") + } + + /// Without the year, a conversation from last July and one from this July + /// read identically. + /// + /// May, like the months the tests above use, is abbreviated the same way by + /// every ICU version. September is not — `Sep` and `Sept` are both current — + /// and pinning one of those would make this test an OS-update tripwire + /// rather than a check on the format. + @Test("adds the year for a conversation from an earlier one") + func earlierYear() throws { + #expect(try label("2024-05-13T09:00:00Z", now: "2026-08-03T10:00:00Z") == "13 May 2024") + } + + @Test("dates a conversation from its summary") + func labelsASummary() throws { + let conversation = ConversationSummary( + id: "17251488000", + title: "Reading list", + lastActivatedAt: "2026-05-13T09:00:00Z", + pinnedAt: nil, + eventsCount: 3 + ) + + #expect( + ConversationDate.activityLabel( + for: conversation, + now: try date("2026-08-03T10:00:00Z"), + calendar: Self.calendar, + locale: Self.locale + ) == "13 May" + ) + } + + /// A row shows no date rather than error text when the library reports + /// something this cannot read. + @Test("dates nothing when the summary's timestamp will not parse") + func labelsAnUnreadableSummary() throws { + let conversation = ConversationSummary( + id: "17251488000", + title: "Reading list", + lastActivatedAt: "not a timestamp", + pinnedAt: nil, + eventsCount: 3 + ) + + #expect( + ConversationDate.activityLabel( + for: conversation, + now: try date("2026-08-03T10:00:00Z"), + calendar: Self.calendar, + locale: Self.locale + ) == nil + ) + } +} diff --git a/apps/macos/Tests/ConversationFilterTests.swift b/apps/macos/Tests/ConversationFilterTests.swift new file mode 100644 index 000000000..e3d7ee335 --- /dev/null +++ b/apps/macos/Tests/ConversationFilterTests.swift @@ -0,0 +1,105 @@ +import Testing + +@testable import JP + +@Suite("ConversationFilter") +struct ConversationFilterTests { + /// Fixed IDs and titles, so an assertion names exactly what it expects. + private let conversations = [ + ConversationSummary( + id: "17855681129", + title: "Accessibility identifiers for driving", + lastActivatedAt: "2026-08-01T10:00:00Z", + pinnedAt: nil, + eventsCount: 116 + ), + ConversationSummary( + id: "17855681250", + title: "jpdrive: the accessibility driver", + lastActivatedAt: "2026-08-01T09:00:00Z", + pinnedAt: nil, + eventsCount: 124 + ), + ConversationSummary( + id: "17855299562", + title: "Café hours", + lastActivatedAt: "2026-08-01T08:00:00Z", + pinnedAt: nil, + eventsCount: 3 + ), + ConversationSummary( + id: "17801582617", + title: nil, + lastActivatedAt: "2026-07-01T08:00:00Z", + pinnedAt: nil, + eventsCount: 4 + ), + ] + + private func ids(_ query: String) -> [String] { + return ConversationFilter.matches(conversations, query: query).map(\.id) + } + + /// Clearing the box restores the list. An empty query meaning "match nothing" + /// would empty the sidebar the moment somebody deleted what they typed. + @Test("a blank query matches everything", arguments: ["", " ", "\n"]) + func blankMatchesEverything(query: String) { + #expect(ConversationFilter.matches(conversations, query: query).count == 4) + } + + @Test("matches anywhere in the title, not only at the start") + func matchesASubstring() { + #expect(ids("driver") == ["17855681250"]) + } + + @Test("ignores case") + func ignoresCase() { + #expect(ids("ACCESSIBILITY") == ["17855681129", "17855681250"]) + } + + /// `localizedStandardContains` folds diacritics, which is what a person typing + /// on a keyboard without the accent expects. + @Test("ignores diacritics") + func ignoresDiacritics() { + #expect(ids("cafe") == ["17855299562"]) + } + + /// An untitled conversation shows a placeholder, and a row a person can read + /// but not search for is a surprise. + @Test("finds untitled conversations by their placeholder") + func findsUntitled() { + #expect(ids("untitled") == ["17801582617"]) + } + + /// Surrounding whitespace comes free with pasting and typing, and no title has + /// a leading space to match anyway. + @Test("trims the query") + func trimsTheQuery() { + #expect(ids(" driver ") == ["17855681250"]) + } + + @Test("keeps the list in order") + func preservesOrder() { + #expect(ids("accessibility") == ["17855681129", "17855681250"]) + } + + @Test("matches nothing when nothing matches") + func matchesNothing() { + #expect(ids("zzz").isEmpty) + } + + /// IDs are timestamps. Searching them would let a query hit rows with no + /// visible reason, which reads as a bug rather than a feature. + @Test("does not match on the conversation ID") + func doesNotMatchIDs() { + #expect(ids("17855681129").isEmpty) + } + + /// The row and the filter have to agree about what an untitled conversation is + /// called, or one of them is lying. + @Test("the displayed title is the one searched") + func displayTitleIsShared() { + #expect(ConversationFilter.displayTitle(of: conversations[3]) == "Untitled") + #expect(ConversationFilter.displayTitle(of: conversations[2]) == "Café hours") + } +} diff --git a/apps/macos/Tests/ConversationOrderTests.swift b/apps/macos/Tests/ConversationOrderTests.swift new file mode 100644 index 000000000..9796867ae --- /dev/null +++ b/apps/macos/Tests/ConversationOrderTests.swift @@ -0,0 +1,110 @@ +import Testing + +@testable import JP + +@Suite("ConversationOrder") +struct ConversationOrderTests { + /// A conversation with a fixed ID, pinned or not. + /// + /// Nothing here reads the timestamps or the event count, so they are the same + /// for every one: what the ordering depends on is the pin and the position. + private func conversation(_ id: String, pinned: Bool = false) -> ConversationSummary { + ConversationSummary( + id: id, + title: "Conversation \(id)", + lastActivatedAt: "2026-08-01T10:00:00Z", + pinnedAt: pinned ? "2026-08-02T09:00:00Z" : nil, + eventsCount: 3 + ) + } + + private func ids(_ conversations: [ConversationSummary]) -> [String] { + ConversationOrder.pinnedFirst(conversations).map(\.id) + } + + @Test("lifts a pinned conversation above the unpinned ones") + func pinnedGoesFirst() { + let listing = [ + conversation("1"), + conversation("2"), + conversation("3", pinned: true), + ] + + #expect(ids(listing) == ["3", "1", "2"]) + } + + /// The library reports conversations most recently active first, and that + /// order has to survive inside each group: pinning is meant to lift one + /// conversation, not to reshuffle the rest. + @Test("keeps the given order inside each group") + func orderWithinGroupsIsKept() { + let listing = [ + conversation("1"), + conversation("2", pinned: true), + conversation("3"), + conversation("4", pinned: true), + ] + + #expect(ids(listing) == ["2", "4", "1", "3"]) + } + + @Test("leaves a list with no pins exactly as it was") + func noPinsChangesNothing() { + let listing = [conversation("1"), conversation("2"), conversation("3")] + + #expect(ids(listing) == ["1", "2", "3"]) + } + + @Test("leaves a list of nothing but pins exactly as it was") + func allPinnedChangesNothing() { + let listing = [ + conversation("1", pinned: true), + conversation("2", pinned: true), + ] + + #expect(ids(listing) == ["1", "2"]) + } + + @Test("orders an empty list") + func emptyList() { + #expect(ConversationOrder.pinnedFirst([]).isEmpty) + } + + private func bareRows(_ listing: [ConversationSummary], selecting: String?) -> Set { + ConversationOrder.rowsWithoutSeparator(in: listing, selecting: selecting) + } + + /// Both lines touching the selection go, not just the one under it: a + /// separator drawn above the selected row cuts across the top of its rounded + /// fill just as visibly as one below cuts the bottom. + @Test("drops the separator on the selected row and the one above it") + func dropsBothSeparatorsTouchingTheSelection() { + let listing = [conversation("1"), conversation("2"), conversation("3")] + + #expect(bareRows(listing, selecting: "2") == ["1", "2"]) + } + + /// There is no row above the first, so only its own line goes. + @Test("drops one separator when the first row is selected") + func firstRowHasNothingAboveIt() { + let listing = [conversation("1"), conversation("2")] + + #expect(bareRows(listing, selecting: "1") == ["1"]) + } + + @Test("draws every separator when nothing is selected") + func noSelectionDropsNothing() { + let listing = [conversation("1"), conversation("2")] + + #expect(bareRows(listing, selecting: nil).isEmpty) + } + + /// A filter can hide the selected conversation while it stays selected, and + /// the rows still on screen all keep their lines. + @Test("draws every separator when the selection is not in the list") + func selectionOutsideTheListDropsNothing() { + let listing = [conversation("1"), conversation("2")] + + #expect(bareRows(listing, selecting: "3").isEmpty) + } +} diff --git a/apps/macos/Tests/ConversationRefTests.swift b/apps/macos/Tests/ConversationRefTests.swift new file mode 100644 index 000000000..94fd505ea --- /dev/null +++ b/apps/macos/Tests/ConversationRefTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing + +@testable import JP + +@Suite("ConversationRef") +struct ConversationRefTests { + private let reference = ConversationRef( + workspacePath: "/tmp/my-workspace", + conversationID: "17251488000", + title: "Reading list" + ) + + /// The URI is the form JP itself uses to reference a conversation, so what + /// lands on the pasteboard is something a person can paste into a query. + /// + /// This is what the `Transferable` conformance exports, and so what both a + /// copy and a drag produce. The conformance itself is a single + /// `ProxyRepresentation` over this property; driving it through + /// `exported(as:)` needs an importable representation, which a reference has + /// no use for until something can accept a drop. + @Test("exports as a jp:// URI") + func exportsAsAURI() { + #expect(reference.uri == "jp://17251488000") + } + + /// A window restored from disk has no title, and still needs one to show. + @Test("falls back to the ID for a window title") + func fallsBackToTheID() { + let untitled = ConversationRef( + workspacePath: "/tmp/my-workspace", + conversationID: "17251488000" + ) + + #expect(untitled.displayTitle == "Conversation 17251488000") + } + + @Test("prefers the title for a window title") + func prefersTheTitle() { + #expect(reference.displayTitle == "Reading list") + } + + /// The system restores window values by encoding them, so a reference has to + /// survive a round trip with the workspace path intact — that path is what + /// lets a restored conversation window read its workspace without a + /// workspace window open. + @Test("survives the round trip the system restores windows through") + func survivesARoundTrip() throws { + let encoded = try JSONEncoder().encode(reference) + let decoded = try JSONDecoder().decode(ConversationRef.self, from: encoded) + + #expect(decoded == reference) + #expect(decoded.workspacePath == "/tmp/my-workspace") + } +} diff --git a/apps/macos/Tests/ConversationSummaryTests.swift b/apps/macos/Tests/ConversationSummaryTests.swift new file mode 100644 index 000000000..1bb9bb789 --- /dev/null +++ b/apps/macos/Tests/ConversationSummaryTests.swift @@ -0,0 +1,132 @@ +import Foundation +import Testing + +@testable import JP + +/// Decoding tests for the hand-maintained mirror of the Rust payload. +/// +/// The payloads here are copied verbatim from the assertions in +/// `crates/jp_ffi/src/lib_tests.rs`. When the Rust side changes shape, its tests +/// fail and so do these, which is the only link between the two definitions. +@Suite("ConversationSummary decoding") +struct ConversationSummaryTests { + private func decode(_ json: String) throws -> [ConversationSummary] { + try JSONDecoder().decode([ConversationSummary].self, from: Data(json.utf8)) + } + + @Test("decodes the payload the library emits") + func decodesLibraryPayload() throws { + let json = """ + [{"id":"17251488000","title":"Reading list",\ + "last_activated_at":"2024-09-02T12:30:00Z","events_count":0}] + """ + + let decoded = try decode(json) + + #expect( + decoded == [ + ConversationSummary( + id: "17251488000", + title: "Reading list", + lastActivatedAt: "2024-09-02T12:30:00Z", + pinnedAt: nil, + eventsCount: 0 + ) + ] + ) + } + + /// The key is present only for a pinned conversation, so its absence is what + /// says a conversation is not pinned. + @Test("decodes the payload a pinned conversation emits") + func decodesPinnedConversation() throws { + let json = """ + [{"id":"17251488000","title":"Reading list",\ + "last_activated_at":"2024-09-02T12:30:00Z",\ + "pinned_at":"2024-09-03T08:00:00Z","events_count":0}] + """ + + let decoded = try decode(json) + + #expect(decoded.first?.pinnedAt == "2024-09-03T08:00:00Z") + #expect(decoded.first?.isPinned == true) + } + + @Test("decodes a missing pin as not pinned") + func decodesMissingPin() throws { + let json = """ + [{"id":"17251488000","title":"Reading list",\ + "last_activated_at":"2024-09-02T12:30:00Z","events_count":0}] + """ + + let decoded = try decode(json) + + #expect(decoded.first?.pinnedAt == nil) + #expect(decoded.first?.isPinned == false) + } + + /// Any conversation JP created from a wall clock carries sub-second + /// precision, so this is the shape the app sees in practice. A `Date` field + /// using `JSONDecoder`'s `.iso8601` strategy would fail here while passing + /// the whole-second case above. + @Test("decodes a timestamp with fractional seconds") + func decodesFractionalSeconds() throws { + let json = """ + [{"id":"17251488000","title":"Reading list",\ + "last_activated_at":"2024-09-02T12:30:00.123456Z","events_count":0}] + """ + + let decoded = try decode(json) + + #expect(decoded.first?.lastActivatedAt == "2024-09-02T12:30:00.123456Z") + } + + /// A conversation keeps no title until one is generated or set. + @Test("decodes a missing title as nil") + func decodesMissingTitle() throws { + let json = """ + [{"id":"17251488000","last_activated_at":"2024-09-02T12:30:00Z","events_count":3}] + """ + + let decoded = try decode(json) + + #expect(decoded.first?.title == nil) + #expect(decoded.first?.eventsCount == 3) + } + + /// A field added on the Rust side must not break an app built against the + /// older shape, so unknown keys are ignored rather than rejected. + @Test("ignores fields it does not know") + func ignoresUnknownFields() throws { + let json = """ + [{"id":"17251488000","title":"Reading list",\ + "last_activated_at":"2024-09-02T12:30:00Z","events_count":0,\ + "some_field_from_a_newer_library":true}] + """ + + let decoded = try decode(json) + + #expect(decoded.count == 1) + } + + @Test("decodes an empty workspace") + func decodesEmptyList() throws { + #expect(try decode("[]").isEmpty) + } + + /// The list is keyed by `id` in SwiftUI, so two conversations must not + /// collide. + @Test("uses the conversation ID as its identity") + func identityIsTheConversationID() throws { + let json = """ + [{"id":"17251488000","title":"A","last_activated_at":"2024-09-02T12:30:00Z",\ + "events_count":0},\ + {"id":"17251488001","title":"B","last_activated_at":"2024-09-02T12:30:00Z",\ + "events_count":0}] + """ + + let decoded = try decode(json) + + #expect(decoded.map(\.id) == ["17251488000", "17251488001"]) + } +} diff --git a/apps/macos/Tests/ConversationTurnTests.swift b/apps/macos/Tests/ConversationTurnTests.swift new file mode 100644 index 000000000..467af967a --- /dev/null +++ b/apps/macos/Tests/ConversationTurnTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing + +@testable import JP + +/// Decoding tests for the hand-maintained mirror of the Rust projection. +/// +/// The payload here is copied verbatim from +/// `events_are_projected_as_turns_of_tagged_json` in +/// `crates/jp_ffi/src/lib_tests.rs`. When the Rust side changes shape, its test +/// fails and so does this one, which is the only link between the two +/// definitions. +@Suite("ConversationTurn decoding") +struct ConversationTurnDecodingTests { + private func decode(_ json: String) throws -> [ConversationTurn] { + try JSONDecoder().decode([ConversationTurn].self, from: Data(json.utf8)) + } + + @Test("decodes the payload the library emits") + func decodesLibraryPayload() throws { + let json = """ + [{"index":0,"events":[\ + {"type":"user_message","timestamp":"2024-09-01T10:00:01Z","author":"Jean",\ + "text":"What does this do?"},\ + {"type":"assistant_message","timestamp":"2024-09-01T10:00:03Z",\ + "text":"It reads conversations."}]}] + """ + + #expect( + try decode(json) == [ + ConversationTurn( + index: 0, + events: [ + .userMessage( + timestamp: "2024-09-01T10:00:01Z", + author: "Jean", + text: "What does this do?" + ), + .assistantMessage( + timestamp: "2024-09-01T10:00:03Z", + text: "It reads conversations." + ), + ] + ) + ] + ) + } + + /// The library numbers a turn by its place among all of them, so a turn it + /// had nothing to show for leaves a gap. Two turns in a row can be 0 and 2. + @Test("keeps the library's turn numbering, gaps and all") + func keepsTheLibrarysNumbering() throws { + let json = """ + [{"index":0,"events":[\ + {"type":"user_message","timestamp":"2024-09-01T10:00:00Z","text":"first"}]},\ + {"index":2,"events":[\ + {"type":"user_message","timestamp":"2024-09-01T10:00:02Z","text":"third"}]}] + """ + + #expect(try decode(json).map(\.index) == [0, 2]) + } + + /// A request authored before a display name was configured has no author, + /// and is still shown as the user's. + @Test("decodes a user message with no author") + func decodesUserMessageWithoutAuthor() throws { + let json = """ + [{"index":0,"events":[\ + {"type":"user_message","timestamp":"2024-09-01T10:00:00Z","text":"hi"}]}] + """ + + let turns = try decode(json) + + #expect( + turns.first?.events == [ + .userMessage(timestamp: "2024-09-01T10:00:00Z", author: nil, text: "hi") + ] + ) + #expect(turns.first?.events.first?.speaker == "You") + } + + /// A presentation added on the Rust side must not break an app built against + /// the older shape: the event it cannot draw is left out and the rest of the + /// turn still arrives. + @Test("skips a presentation it does not know, keeping the rest of the turn") + func skipsUnknownPresentation() throws { + let json = """ + [{"index":0,"events":[\ + {"type":"some_future_presentation","timestamp":"2024-09-01T10:00:00Z"},\ + {"type":"user_message","timestamp":"2024-09-01T10:00:01Z","text":"hi"}]}] + """ + + #expect( + try decode(json).first?.events == [ + .userMessage(timestamp: "2024-09-01T10:00:01Z", author: nil, text: "hi") + ] + ) + } + + /// Leniency stops at the `type` tag. A presentation this build *does* know, + /// arriving without the fields it promises, is a wire-format mistake and + /// fails rather than being quietly dropped. + @Test("fails on a known presentation missing its fields") + func failsOnMalformedKnownPresentation() { + let json = """ + [{"index":0,"events":[\ + {"type":"user_message","timestamp":"2024-09-01T10:00:00Z"}]}] + """ + + #expect(throws: (any Error).self) { + try decode(json) + } + } + + @Test("decodes an empty conversation") + func decodesEmptyList() throws { + #expect(try decode("[]").isEmpty) + } + + @Test("reads the timestamp and speaker of either presentation") + func readsTimestampAndSpeaker() throws { + let json = """ + [{"index":0,"events":[\ + {"type":"user_message","timestamp":"2024-09-01T10:00:00Z","author":"Jean","text":"hi"},\ + {"type":"assistant_message","timestamp":"2024-09-01T10:00:01Z","text":"hello"}]}] + """ + + let events = try #require(decode(json).first?.events) + + #expect(events.map(\.timestamp) == ["2024-09-01T10:00:00Z", "2024-09-01T10:00:01Z"]) + #expect(events.map(\.speaker) == ["Jean", "Assistant"]) + } +} diff --git a/apps/macos/Tests/DebugStateTests.swift b/apps/macos/Tests/DebugStateTests.swift new file mode 100644 index 000000000..a2d6925f6 --- /dev/null +++ b/apps/macos/Tests/DebugStateTests.swift @@ -0,0 +1,126 @@ +import Foundation +import Testing + +@testable import JP + +/// Nested in ``WorkspaceSuite`` because these write `JP_DEBUG_STATE_DIR`, which the +/// whole process shares. +extension WorkspaceSuite { + @MainActor + @Suite("DebugState") + struct DebugStateTests { + /// Run `body` with `JP_DEBUG_STATE_DIR` set to `directory`, and unset after. + /// + /// Unset rather than restored: the variable belongs to a harness driving the + /// app, so no test run has one to put back. + private func withStateDirectory(_ directory: URL?, _ body: () throws -> Void) throws { + if let directory { + setenv(DebugState.variable, directory.path(percentEncoded: false), 1) + } else { + unsetenv(DebugState.variable) + } + defer { unsetenv(DebugState.variable) } + + try body() + } + + /// The shipping configuration. A regression here would point the app's + /// recents at a file nothing reads, silently. + @Test("uses the system list when the variable is unset") + func usesTheSystemListWhenUnset() throws { + try withStateDirectory(nil) { + #expect(DebugState.directory == nil) + #expect(DebugState.defaultStore() is DocumentControllerRecents) + } + } + + /// The isolation the whole driving setup rests on: with the variable set, the + /// app must not read or write the list it shares with the system. + @Test("uses a file inside the state directory when the variable is set") + func usesAFileWhenSet() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + try withStateDirectory(root) { + let store = DebugState.defaultStore() + let file = try #require(store as? FileRecents) + #expect(file.path == root.appendingPathComponent("recents.json")) + } + } + + @Test("ignores an empty variable") + func ignoresAnEmptyVariable() throws { + setenv(DebugState.variable, "", 1) + defer { unsetenv(DebugState.variable) } + + #expect(DebugState.directory == nil) + #expect(DebugState.defaultStore() is DocumentControllerRecents) + } + + /// How a harness that launched the app through `open(1)` learns which + /// process it got. + @Test("records the process id") + func recordsTheProcessID() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + try withStateDirectory(root) { + DebugState.recordProcessID() + + let recorded = try String( + contentsOf: root.appendingPathComponent("pid"), + encoding: .utf8 + ) + #expect(recorded == "\(getpid())\n") + } + } + + /// A profiler subtracts this from every address it samples, and a recorder + /// that attached to an already-running app has no other way to learn it: the + /// kernel's image-load events only exist in a trace that was already + /// recording when dyld mapped the image. + /// + /// The format is a contract, not just a number. `Session::reported_slide` on + /// the Rust side parses it as an unsigned integer, so a sign or an `0x` + /// prefix would parse as nothing there and silently fall back to recovering + /// the slide from the trace — which is the failure this file exists to + /// avoid. + @Test("records the main image's ASLR slide") + func recordsTheImageSlide() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + try withStateDirectory(root) { + DebugState.recordProcessID() + + let recorded = try String( + contentsOf: root.appendingPathComponent("slide"), + encoding: .utf8 + ) + #expect(recorded == "\(_dyld_get_image_vmaddr_slide(0))\n") + + let text = recorded.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(UInt64(text) != nil) + } + } + + /// The tools name a directory that does not exist yet, then launch the app + /// into it. + @Test("creates the state directory to record into") + func createsTheStateDirectory() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let state = root.appendingPathComponent("state") + + try withStateDirectory(state) { + DebugState.recordProcessID() + + #expect( + FileManager.default.fileExists( + atPath: state.appendingPathComponent("pid").path(percentEncoded: false) + ) + ) + } + } + } +} diff --git a/apps/macos/Tests/MarkdownTests.swift b/apps/macos/Tests/MarkdownTests.swift new file mode 100644 index 000000000..079c73a9d --- /dev/null +++ b/apps/macos/Tests/MarkdownTests.swift @@ -0,0 +1,277 @@ +import AppKit +import Testing + +@testable import JP + +/// What markdown turns into, as text a TextKit view draws. +/// +/// The style is fixed rather than taken from ``Theme``, so every number and +/// colour asserted below is one this file states. +@Suite("Markdown") +struct MarkdownTests { + /// Distinct, obviously-not-real colours, so an assertion says which one was + /// applied rather than which appearance was resolved. + var style: MarkdownStyle { + MarkdownStyle( + body: .systemFont(ofSize: 14), + monospaced: .monospacedSystemFont(ofSize: 13, weight: .regular), + text: ThemeColor.srgb(0x11_1111), + secondary: ThemeColor.srgb(0x88_8888), + codeBackground: ThemeColor.srgb(0xEE_EEEE), + codeText: ThemeColor.srgb(0x22_2222), + link: ThemeColor.srgb(0x00_00FF), + indent: 20, + tableColumnWidth: 100, + blockSpacing: 10, + lineSpacing: 3, + eventSpacing: 18, + turnSpacing: 40 + ) + } + + private func render(_ source: String) -> NSAttributedString { + Markdown.attributed(source, style: style) + } + + private func paragraph(of rendered: NSAttributedString, at index: Int) -> NSParagraphStyle? + { + rendered.attribute(.paragraphStyle, at: index, effectiveRange: nil) as? NSParagraphStyle + } + + private func font(of rendered: NSAttributedString, at index: Int) -> NSFont? { + rendered.attribute(.font, at: index, effectiveRange: nil) as? NSFont + } + + /// Foundation's parser returns the blocks with nothing between them, so the + /// separators are the renderer's to put back. Without this a heading runs + /// into the paragraph under it. + @Test("separates blocks with newlines and leaves none trailing") + func separatesBlocks() { + let rendered = render( + """ + # Heading + + First paragraph. + + Second paragraph. + """) + + #expect(rendered.string == "Heading\nFirst paragraph.\nSecond paragraph.") + } + + @Test("draws a bullet before each item of an unordered list") + func drawsBullets() { + #expect(render("- first\n- second").string == "•\tfirst\n•\tsecond") + } + + @Test("numbers the items of an ordered list") + func numbersOrderedItems() { + #expect(render("1. first\n2. second").string == "1.\tfirst\n2.\tsecond") + } + + /// The number is the one written in the source, not the item's position: + /// a list starting at 3 is displayed starting at 3. + @Test("keeps the ordinal the source gave an item") + func keepsSourceOrdinals() { + #expect(render("3. third\n4. fourth").string == "3.\tthird\n4.\tfourth") + } + + /// The marker hangs in the indent its own level added and the text sits at + /// the indent, so a wrapped line lines up under the first rather than under + /// the bullet. + @Test("hangs a list marker outside the text it labels") + func hangsTheMarker() { + let rendered = render("- item") + let paragraph = paragraph(of: rendered, at: 0) + + #expect(paragraph?.firstLineHeadIndent == 0) + #expect(paragraph?.headIndent == 20) + #expect(paragraph?.tabStops.first?.location == 20) + } + + @Test("indents a nested list one level further") + func indentsNestedLists() { + let rendered = render("- outer\n - inner") + let inner = rendered.string.distance( + from: rendered.string.startIndex, + to: rendered.string.range(of: "inner")?.lowerBound ?? rendered.string.startIndex + ) + + #expect(paragraph(of: rendered, at: inner)?.headIndent == 40) + } + + @Test("draws a heading larger than body text, and in bold") + func drawsHeadings() { + let first = font(of: render("# One"), at: 0) + let third = font(of: render("### Three"), at: 0) + + #expect(first?.pointSize == 22) + #expect(third?.pointSize == 16) + #expect(first?.fontDescriptor.symbolicTraits.contains(.bold) == true) + } + + /// A heading past the third is the body size in bold: another distinct size + /// would be a difference nobody can see. + @Test("draws a deep heading at body size") + func drawsDeepHeadingsAtBodySize() { + #expect(font(of: render("##### Five"), at: 0)?.pointSize == 14) + } + + @Test("applies emphasis to the emphasized run alone") + func appliesEmphasis() { + let rendered = render("plain **bold** plain") + let bold = 6 + + #expect(rendered.string == "plain bold plain") + #expect( + font(of: rendered, at: bold)?.fontDescriptor.symbolicTraits.contains(.bold) == true) + #expect( + font(of: rendered, at: 0)?.fontDescriptor.symbolicTraits.contains(.bold) == false) + } + + @Test("sets an inline code span in the monospaced font, on the code background") + func stylesInlineCode() { + let rendered = render("run `jp query` now") + let code = 4 + + #expect(rendered.string == "run jp query now") + #expect(font(of: rendered, at: code) == style.monospaced) + #expect( + rendered.attribute(.backgroundColor, at: code, effectiveRange: nil) as? NSColor + == style.codeBackground + ) + // The prose either side keeps the body font and no background. + #expect(font(of: rendered, at: 0) == style.body) + #expect(rendered.attribute(.backgroundColor, at: 0, effectiveRange: nil) == nil) + } + + /// A fenced block keeps its own newlines, and loses the one before the + /// closing fence — which would otherwise draw an empty last line inside the + /// block's background. + @Test("keeps a code block's lines and drops the fence's trailing newline") + func stylesCodeBlocks() { + let rendered = render("```swift\nlet x = 1\nprint(x)\n```") + + #expect(rendered.string == "let x = 1\nprint(x)") + #expect(font(of: rendered, at: 0) == style.monospaced) + #expect( + rendered.attribute(.backgroundColor, at: rendered.length - 1, effectiveRange: nil) + as? NSColor == style.codeBackground + ) + } + + @Test("carries a link's destination, colour and underline") + func stylesLinks() { + let rendered = render("see [the docs](https://example.com/x) for more") + let link = 4 + + #expect(rendered.string == "see the docs for more") + #expect( + rendered.attribute(.link, at: link, effectiveRange: nil) as? URL + == URL(string: "https://example.com/x") + ) + #expect( + rendered.attribute(.foregroundColor, at: link, effectiveRange: nil) as? NSColor + == style.link + ) + } + + @Test("indents a block quote and dims it") + func stylesBlockQuotes() { + let rendered = render("> quoted") + + #expect(rendered.string == "quoted") + #expect(paragraph(of: rendered, at: 0)?.headIndent == 20) + #expect( + rendered.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor + == style.secondary + ) + } + + /// A soft break inside a paragraph is a space, per CommonMark, and the + /// terminal renderer reflows the same way. A hard break is a new line. + @Test("reflows a soft break and honours a hard one") + func handlesLineBreaks() { + #expect(render("one\ntwo").string == "one two") + #expect(render("one \ntwo").string == "one\ntwo") + } + + /// A row is one line of tab-separated cells, not one line per cell. The tab is + /// what carries a cell to its column, so its presence is the assertion. + @Test("lays a table row out as one line of tab-separated cells") + func laysOutTableRows() { + let rendered = render( + """ + | container | samples | + | --- | --- | + | VStack | 2442 | + """) + + #expect(rendered.string == "container\tsamples\nVStack\t2442") + } + + /// One stop per column boundary, so the second cell starts where the second + /// column does. The first needs none: it starts at the paragraph's own edge. + @Test("puts a tab stop at each column boundary") + func stopsAtColumnBoundaries() { + let rendered = render("| a | b | c |\n| --- | --- | --- |\n| 1 | 2 | 3 |") + + #expect(paragraph(of: rendered, at: 0)?.tabStops.map(\.location) == [100, 200]) + } + + /// The one piece of table styling the source states outright. `---:` in the + /// separator row right-aligns a column, and Foundation reports it, so a column + /// of numbers lines up on its digits. + @Test("takes each column's alignment from the source") + func alignsColumnsAsWritten() { + let rendered = render("| a | b | c |\n| :-- | :-: | --: |\n| 1 | 2 | 3 |") + let stops = paragraph(of: rendered, at: 0)?.tabStops + + // The first column has no stop, so these are columns two and three. + #expect(stops?.map(\.alignment) == [.center, .right]) + } + + @Test("draws a table's header row in bold and its body rows plain") + func boldsTheHeaderRow() { + let rendered = render("| head |\n| --- |\n| body |") + let body = rendered.string.distance( + from: rendered.string.startIndex, + to: rendered.string.range(of: "body")?.lowerBound ?? rendered.string.startIndex + ) + + #expect( + font(of: rendered, at: 0)?.fontDescriptor.symbolicTraits.contains(.bold) == true) + #expect( + font(of: rendered, at: body)?.fontDescriptor.symbolicTraits.contains(.bold) == false + ) + } + + /// Inline styling inside a cell survives, which is what says the cells go + /// through the same run walk as prose rather than being flattened to plain + /// text on the way into a row. + @Test("keeps inline styling inside a cell") + func stylesInsideCells() { + let rendered = render("| a |\n| --- |\n| `code` |") + let cell = rendered.string.distance( + from: rendered.string.startIndex, + to: rendered.string.range(of: "code")?.lowerBound ?? rendered.string.startIndex + ) + + #expect(font(of: rendered, at: cell) == style.monospaced) + } + + /// A table is one block, so the rows sit against each other and the spacing + /// belongs to whatever follows. + @Test("separates a table from the prose around it") + func separatesTablesFromProse() { + let rendered = render("before\n\n| a |\n| --- |\n| 1 |\n\nafter") + + #expect(rendered.string == "before\na\n1\nafter") + } + + @Test("renders text that is not markdown as itself") + func rendersPlainText() { + #expect(render("just a sentence.").string == "just a sentence.") + #expect(render("").string == "") + } +} diff --git a/apps/macos/Tests/RecentWorkspacesTests.swift b/apps/macos/Tests/RecentWorkspacesTests.swift new file mode 100644 index 000000000..fa1dcbf89 --- /dev/null +++ b/apps/macos/Tests/RecentWorkspacesTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing + +@testable import JP + +/// Outside ``WorkspaceSuite``, because each test owns the file its list is kept in +/// and so touches no state shared with the rest of the process. Backing these by +/// `NSDocumentController` would mean every run clearing the developer's own +/// `File ▸ Open Recent`. +@MainActor +@Suite("RecentWorkspaces") +struct RecentWorkspacesTests { + /// A list backed by a file inside `root`. + private func makeRecents(in root: URL) -> RecentWorkspaces { + RecentWorkspaces(store: FileRecents(path: root.appendingPathComponent("recents.json"))) + } + + @Test("starts empty") + func startsEmpty() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + #expect(makeRecents(in: root).urls.isEmpty) + } + + @Test("records an opened workspace") + func recordsAnOpenedWorkspace() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let workspace = URL(fileURLWithPath: try makeWorkspace(in: root)).canonicalized + + let recents = makeRecents(in: root) + recents.note(workspace) + + #expect(recents.urls == [workspace]) + } + + /// The temporary directory lives under a symlink, so a path recorded as given + /// would never match the canonical one a window is keyed by. + @Test("records a workspace under its canonical path") + func canonicalizesTheRecordedPath() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let workspace = URL(fileURLWithPath: try makeWorkspace(in: root)) + + let recents = makeRecents(in: root) + recents.note(workspace) + + #expect(recents.urls == [workspace.canonicalized]) + } + + /// A path read back out of the list is spelled the way a window is keyed by it. + /// `URL(fileURLWithPath:)` marks an existing directory as one, so without + /// normalizing, every entry carries a trailing slash the window keys do not. + @Test("records a workspace without a trailing slash") + func stripsATrailingSlash() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let workspace = URL(fileURLWithPath: try makeWorkspace(in: root)) + + let recents = makeRecents(in: root) + recents.note(workspace) + + let recorded = try #require(recents.urls.first) + #expect(!recorded.path(percentEncoded: false).hasSuffix("/")) + } + + /// Reopening moves a workspace back to the front, which is what makes the + /// menu ordering useful. + @Test("puts the most recently opened workspace first") + func putsTheMostRecentFirst() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let first = URL(fileURLWithPath: try makeWorkspace(in: root, named: "first")) + .canonicalized + let second = URL(fileURLWithPath: try makeWorkspace(in: root, named: "second")) + .canonicalized + + let recents = makeRecents(in: root) + recents.note(first) + recents.note(second) + recents.note(first) + + #expect(recents.urls == [first, second]) + } + + /// A workspace can be deleted between launches, and offering to open one that + /// is gone produces an error the user cannot act on. + @Test("drops a workspace that no longer exists") + func dropsAMissingWorkspace() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let kept = URL(fileURLWithPath: try makeWorkspace(in: root, named: "kept")) + .canonicalized + let removed = URL(fileURLWithPath: try makeWorkspace(in: root, named: "removed")) + .canonicalized + + let recents = makeRecents(in: root) + recents.note(kept) + recents.note(removed) + #expect(recents.urls == [removed, kept]) + + try FileManager.default.removeItem(at: removed) + + // A fresh instance reads the stored list, as a relaunch would. + #expect(makeRecents(in: root).urls == [kept]) + } + + @Test("clears the list") + func clearsTheList() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let workspace = URL(fileURLWithPath: try makeWorkspace(in: root)).canonicalized + + let recents = makeRecents(in: root) + recents.note(workspace) + recents.clear() + + #expect(recents.urls.isEmpty) + #expect(makeRecents(in: root).urls.isEmpty) + } +} diff --git a/apps/macos/Tests/RecentsStoreTests.swift b/apps/macos/Tests/RecentsStoreTests.swift new file mode 100644 index 000000000..1c99831fb --- /dev/null +++ b/apps/macos/Tests/RecentsStoreTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing + +@testable import JP + +/// ``FileRecents`` on its own, without the canonicalizing and pruning +/// ``RecentWorkspaces`` layers on top. Paths here need not exist on disk. +@MainActor +@Suite("FileRecents") +struct FileRecentsTests { + /// A store at `recents.json` inside a directory of its own. + private func makeStore(in root: URL) -> FileRecents { + FileRecents(path: root.appendingPathComponent("recents.json")) + } + + @Test("reads an absent file as an empty list") + func readsAnAbsentFileAsEmpty() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + #expect(makeStore(in: root).urls().isEmpty) + } + + @Test("reads back what it wrote, most recent first") + func roundTripsInOrder() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + let store = makeStore(in: root) + store.note(URL(fileURLWithPath: "/one")) + store.note(URL(fileURLWithPath: "/two")) + + #expect(store.urls().map { $0.path(percentEncoded: false) } == ["/two", "/one"]) + } + + @Test("moves a repeated path to the front rather than duplicating it") + func movesARepeatedPathToTheFront() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + let store = makeStore(in: root) + store.note(URL(fileURLWithPath: "/one")) + store.note(URL(fileURLWithPath: "/two")) + store.note(URL(fileURLWithPath: "/one")) + + #expect(store.urls().map { $0.path(percentEncoded: false) } == ["/one", "/two"]) + } + + @Test("keeps only the most recent paths") + func keepsOnlyTheMostRecentPaths() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + let store = makeStore(in: root) + for index in 0...FileRecents.capacity { + store.note(URL(fileURLWithPath: "/workspace-\(index)")) + } + + let paths = store.urls().map { $0.path(percentEncoded: false) } + #expect(paths.count == FileRecents.capacity) + #expect(paths.first == "/workspace-\(FileRecents.capacity)") + #expect(paths.last == "/workspace-1") + } + + /// The file is a harness's to write, so it can arrive malformed. An empty list + /// costs the menu its entries; refusing to produce one would cost the window + /// its workspace. + @Test("reads a malformed file as an empty list") + func readsAMalformedFileAsEmpty() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let store = makeStore(in: root) + try "not json".write(to: store.path, atomically: true, encoding: .utf8) + + #expect(store.urls().isEmpty) + } + + @Test("clears the file") + func clearsTheFile() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + let store = makeStore(in: root) + store.note(URL(fileURLWithPath: "/one")) + store.clear() + + #expect(store.urls().isEmpty) + } + + /// The tools write the file before the app has ever run, into a directory that + /// may not exist yet. + @Test("creates the directory it writes into") + func createsTheDirectory() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let store = FileRecents(path: root.appendingPathComponent("state/recents.json")) + + store.note(URL(fileURLWithPath: "/one")) + + #expect(store.urls().map { $0.path(percentEncoded: false) } == ["/one"]) + } +} diff --git a/apps/macos/Tests/TestSandbox.swift b/apps/macos/Tests/TestSandbox.swift new file mode 100644 index 000000000..bf2b64913 --- /dev/null +++ b/apps/macos/Tests/TestSandbox.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing + +/// The suite every test that touches process-wide state belongs to. +/// +/// Opening a workspace reads `JP_USER_DATA_DIR` from the environment, and the +/// recent-workspaces list is system state shared by the whole process. Neither +/// can be made per-test, so the tests that use them are serialized instead — and +/// serialized *together*, which is why they are nested here: `.serialized` orders +/// the tests within a suite, and sibling suites still run alongside each other. +/// +/// Nesting from another file is what the `extension WorkspaceSuite` declarations +/// in the sibling test files are doing. +/// +/// Tests that touch none of this — decoding, ordering, presentation — stay +/// outside and run in parallel. +@Suite("Workspace", .serialized) +struct WorkspaceSuite {} + +/// Create a disposable directory tree for one test, and point user-local storage +/// inside it. +/// +/// Paired with ``removeSandbox(_:)`` through `defer` rather than owned by an +/// object with a `deinit`: ARC may release such an object right after its last +/// mention, which can be before the awaited work that uses the directory runs, +/// deleting the fixture out from under the test. +/// +/// Only safe to call from inside ``WorkspaceSuite``, because it writes the +/// environment the whole process shares. Use ``makeTemporaryDirectory()`` for a +/// directory without that constraint. +func makeSandbox() throws -> URL { + let root = try makeTemporaryDirectory() + + // Keep the user-local conversation store, which opening a workspace creates, + // inside this test's own directory rather than the real user data directory. + setenv("JP_USER_DATA_DIR", root.appendingPathComponent("user-data").path, 1) + unsetenv("XDG_DATA_HOME") + + return root +} + +/// Create a disposable directory for one test. +/// +/// Writes no environment and touches nothing outside itself, so it is safe from +/// any suite. Paired with ``removeSandbox(_:)``. +func makeTemporaryDirectory() throws -> URL { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("jp-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root +} + +func removeSandbox(_ root: URL) { + try? FileManager.default.removeItem(at: root) +} + +/// Create a workspace root with an empty store, and return its path. +/// +/// The workspace ID is written rather than left for JP to mint, because JP +/// derives one from the current millisecond: two workspaces created in the same +/// millisecond would share an ID, and with it the user-local store keyed by it. +func makeWorkspace(in root: URL, named name: String = "my-workspace") throws -> String { + let workspace = root.appendingPathComponent(name) + let store = workspace.appendingPathComponent(".jp") + try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true) + + // `Id::load` reads the last line, and rejects anything that is not five + // characters of `[0-9a-z]`. + let preamble = "DO NOT EDIT THIS FILE! IT IS AUTO-GENERATED BY JP." + try "\(preamble)\n\(makeWorkspaceID())\n" + .write(to: store.appendingPathComponent(".id"), atomically: true, encoding: .utf8) + + return workspace.path +} + +/// Create a directory with no workspace in it, and return its path. +func makeBareDirectory(in root: URL, named name: String) throws -> String { + let directory = root.appendingPathComponent(name) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.path +} + +/// A workspace ID in the shape JP accepts: five characters of `[0-9a-z]`. +private func makeWorkspaceID() -> String { + let alphabet = Array("0123456789abcdefghijklmnopqrstuvwxyz") + return String((0..<5).map { _ in alphabet.randomElement() ?? "0" }) +} diff --git a/apps/macos/Tests/ThemeTests.swift b/apps/macos/Tests/ThemeTests.swift new file mode 100644 index 000000000..693fefdf2 --- /dev/null +++ b/apps/macos/Tests/ThemeTests.swift @@ -0,0 +1,124 @@ +import AppKit +import Testing + +@testable import JP + +/// Tests for the palette and the mechanism that resolves it. +/// +/// Not a pin of every hex value: those are declared once in `Theme.swift` and +/// re-typing them here would only assert that copy-paste works. What is worth +/// holding is the wiring — that a colour resolves to its light half under a light +/// appearance and its dark half under a dark one, and that a value survives the +/// trip through `NSColor` unchanged. +@Suite("Theme") +struct ThemeTests { + /// Every colour the palette declares, so a test can hold all of them to the + /// same rule at once. + private static let palette: [(name: String, color: ThemeColor)] = [ + ("sidebarBackground", Theme.sidebarBackground), + ("selectedRowBackground", Theme.selectedRowBackground), + ("paneDivider", Theme.paneDivider), + ("rowSeparator", Theme.rowSeparator), + ("searchFieldBackground", Theme.searchFieldBackground), + ("editorBackground", Theme.editorBackground), + ("bodyText", Theme.bodyText), + ("secondaryText", Theme.secondaryText), + ("accent", Theme.accent), + ("inlineCodeBackground", Theme.inlineCodeBackground), + ("inlineCodeText", Theme.inlineCodeText), + ("tagBackground", Theme.tagBackground), + ("tagText", Theme.tagText), + ] + + /// The hex `color` resolves to under `appearance`, read back off the drawn + /// colour rather than off the declaration. + /// + /// A dynamic `NSColor` reports nothing about its components until it is + /// resolved against an appearance, which is what `usingColorSpace` after + /// `performAsCurrentDrawingAppearance` does here. + private func drawn(_ color: ThemeColor, under appearance: NSAppearance) -> UInt32? { + var resolved: NSColor? + appearance.performAsCurrentDrawingAppearance { + resolved = color.nsColor.usingColorSpace(.sRGB) + } + + guard let resolved else { return nil } + + let component = { (value: CGFloat) in UInt32((value * 255).rounded()) } + return component(resolved.redComponent) << 16 + | component(resolved.greenComponent) << 8 + | component(resolved.blueComponent) + } + + @Test("draws its light half under a light appearance") + func resolvesLight() throws { + let aqua = try #require(NSAppearance(named: .aqua)) + + for entry in Self.palette { + #expect( + drawn(entry.color, under: aqua) == entry.color.light, + "\(entry.name) drew the wrong colour in light appearance" + ) + } + } + + @Test("draws its dark half under a dark appearance") + func resolvesDark() throws { + let darkAqua = try #require(NSAppearance(named: .darkAqua)) + + for entry in Self.palette { + #expect( + drawn(entry.color, under: darkAqua) == entry.color.dark, + "\(entry.name) drew the wrong colour in dark appearance" + ) + } + } + + /// One line between the panes, dozens between the rows: at the same weight + /// the list reads as a grid, so the two are deliberately different. + @Test("separates rows more lightly than it separates panes") + func rowsAreSeparatedMoreLightly() { + #expect(Theme.rowSeparator.light > Theme.paneDivider.light) + } + + /// The two halves of every colour differ. A pair that matched would be a + /// half-finished copy-paste, and it looks like a working app right up until + /// somebody switches appearance and finds white text on white. + @Test("gives every colour two distinct halves") + func halvesDiffer() { + for entry in Self.palette { + #expect( + entry.color.light != entry.color.dark, + "\(entry.name) is the same colour in both appearances" + ) + } + } + + /// The accessibility appearances are variants of the two base ones and have + /// names of their own, so matching by name alone would send a high-contrast + /// dark window down the light path. + @Test("treats the high-contrast dark appearance as dark") + func highContrastDarkIsDark() throws { + let variant = try #require(NSAppearance(named: .accessibilityHighContrastDarkAqua)) + + #expect(variant.isDark) + } + + @Test("treats the high-contrast light appearance as light") + func highContrastLightIsLight() throws { + let variant = try #require(NSAppearance(named: .accessibilityHighContrastAqua)) + + #expect(variant.isDark == false) + } + + /// The channels are unpacked in the right order, which a grey would hide. + @Test("unpacks a hex value into its channels") + func unpacksChannels() throws { + let color = try #require(ThemeColor.srgb(0x11_22_33).usingColorSpace(.sRGB)) + + #expect((color.redComponent * 255).rounded() == 0x11) + #expect((color.greenComponent * 255).rounded() == 0x22) + #expect((color.blueComponent * 255).rounded() == 0x33) + #expect(color.alphaComponent == 1) + } +} diff --git a/apps/macos/Tests/TraceTests.swift b/apps/macos/Tests/TraceTests.swift new file mode 100644 index 000000000..e66c69151 --- /dev/null +++ b/apps/macos/Tests/TraceTests.swift @@ -0,0 +1,173 @@ +import Foundation +import Testing + +@testable import JP + +/// One line exactly as the app writes it. +/// +/// Pinned here and in `.config/jp/tools/src/debug_app/trace_tests.rs`, character +/// for character. Nothing else checks that the writer and the reader agree on +/// the format: if one of these two strings is edited alone, the other test is +/// what says so. +private let appLine = """ + {"timestamp":"2026-08-02T11:04:12.418293Z","level":"INFO","target":"JP.Transcript",\ + "fields":{"message":"transcript.render","duration_ms":84.219,"event_count":847,\ + "footprint_mb":412},"spans":[{"name":"conversation.select"}]} + """ + +@Suite("Trace") +struct TraceTests { + @Test("writes the line the tooling parses") + func writesThePinnedLine() throws { + let line = try #require( + Trace.line( + timestamp: "2026-08-02T11:04:12.418293Z", + level: .info, + target: "JP.Transcript", + message: "transcript.render", + fields: [ + ("duration_ms", 84.219), + ("event_count", 847), + ("footprint_mb", 412), + ], + spans: ["conversation.select"] + ) + ) + + #expect(line == appLine) + } + + /// The parser treats `spans` as optional, and most events are not nested + /// inside anything. + @Test("leaves the span stack out when there is none") + func omitsAnEmptySpanStack() throws { + let line = try #require( + Trace.line( + timestamp: "2026-08-02T11:04:10.000000Z", + level: .info, + target: "JP.Trace", + message: "trace.origin", + fields: [("timebase_numer", 125), ("timebase_denom", 3)], + spans: [] + ) + ) + + #expect( + line == """ + {"timestamp":"2026-08-02T11:04:10.000000Z","level":"INFO","target":"JP.Trace",\ + "fields":{"message":"trace.origin","timebase_numer":125,"timebase_denom":3}} + """ + ) + } + + /// RFC 3339, UTC, fractional seconds. A timestamp in local time or without + /// the fraction still parses, and lands the event in the wrong place on a + /// timeline drawn beside `jp`'s. + @Test("formats timestamps as UTC to the microsecond") + func formatsTimestamps() { + #expect( + Trace.timestamp(Date(timeIntervalSince1970: 1_785_668_652.418293)) + == "2026-08-02T11:04:12.418293Z" + ) + #expect( + Trace.timestamp(Date(timeIntervalSince1970: 0)) == "1970-01-01T00:00:00.000000Z") + } + + @Test("reports the process footprint") + func reportsTheFootprint() throws { + let footprint = try #require(Trace.footprintMB()) + + // A live process occupies something, and a footprint of hundreds of + // gigabytes would mean the struct was read as the wrong shape. + #expect(footprint > 0) + #expect(footprint < 100_000) + } + + @Test("converts mach ticks to milliseconds") + func convertsMachTicks() { + let timebase = MachTimebase.current + // Exactly one second's worth of ticks, whatever this machine counts in. + let ticks = + UInt64(1_000_000_000) * UInt64(timebase.denominator) + / UInt64(timebase.numerator) + + #expect(abs(Trace.milliseconds(ticks) - 1000) < 0.01) + } +} + +/// Nested in ``WorkspaceSuite`` because these read `JP_DEBUG_STATE_DIR`, which +/// the whole process shares. +extension WorkspaceSuite { + @Suite("TraceWriter") + struct TraceWriterTests { + /// The shipping configuration, and the one thing this must never get + /// wrong: an installed app writing a trace file into someone's disk + /// would be a defect, not a feature. + @Test("creates nothing without a state directory") + func createsNothingWithoutADirectory() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + #expect(TraceWriter(directory: nil, fileName: Trace.fileName) == nil) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } + + /// The process-wide sink, resolved from the environment the test host + /// runs under. Serialized with the tests that set that variable, so it + /// is unset here. + @Test("records nothing when the app was launched as it ships") + func recordsNothingWhenLaunchedNormally() { + #expect(ProcessInfo.processInfo.environment[DebugState.variable] == nil) + #expect(Trace.isRecording == false) + #expect(Trace.url == nil) + + // Reaches every sink there is. Nothing to assert but that it neither + // crashes nor has a file to write to. + Trace.event("test.event") + Trace.interval("test.interval").end() + } + + @Test("appends one line per event") + func appendsOneLinePerEvent() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + let writer = try #require(TraceWriter(directory: root, fileName: Trace.fileName)) + writer.append("first") + writer.append("second") + + #expect(writer.url == root.appendingPathComponent("trace.jsonl")) + #expect(try String(contentsOf: writer.url, encoding: .utf8) == "first\nsecond\n") + } + + /// The directory a harness names does not exist yet when it launches the + /// app into it. + @Test("creates the directory it was pointed at") + func createsTheDirectory() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let state = root.appendingPathComponent("state") + + let writer = try #require(TraceWriter(directory: state, fileName: Trace.fileName)) + writer.append("line") + + #expect(try String(contentsOf: writer.url, encoding: .utf8) == "line\n") + } + + /// A relaunch truncates the file, and a second writer on the same path + /// must not overwrite what the first one wrote. + @Test("appends to a file that already has lines in it") + func appendsToAnExistingFile() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + let first = try #require(TraceWriter(directory: root, fileName: Trace.fileName)) + first.append("first") + + let second = try #require(TraceWriter(directory: root, fileName: Trace.fileName)) + second.append("second") + + #expect(try String(contentsOf: second.url, encoding: .utf8) == "first\nsecond\n") + } + } +} diff --git a/apps/macos/Tests/TranscriptTextViewTests.swift b/apps/macos/Tests/TranscriptTextViewTests.swift new file mode 100644 index 000000000..2ca1b5711 --- /dev/null +++ b/apps/macos/Tests/TranscriptTextViewTests.swift @@ -0,0 +1,77 @@ +import AppKit +import Testing + +@testable import JP + +/// How the transcript's text view is set up. +/// +/// Configuration rather than behaviour, and worth pinning because each of these +/// is a line that looks like tidying and is not: the transcript reads correctly +/// with any of them wrong, and then misbehaves in a way that looks like a layout +/// bug. +@Suite("TranscriptTextView") +@MainActor +struct TranscriptTextViewTests { + private func configured() -> NSTextView { + let textView = NSTextView(usingTextLayoutManager: false) + TranscriptTextView.configure(textView) + return textView + } + + /// The pointing hand over a link is this dictionary and nothing else. The + /// default carries a colour and an underline alongside it, which would draw + /// over the ones the document already has — so the cursor is kept and the rest + /// dropped, rather than the whole dictionary emptied. + @Test("keeps the pointing hand over links without AppKit's link styling") + func stylesLinkCursorOnly() { + let attributes = configured().linkTextAttributes ?? [:] + + #expect(attributes[.cursor] as? NSCursor == NSCursor.pointingHand) + #expect(attributes[.foregroundColor] == nil) + #expect(attributes[.underlineStyle] == nil) + } + + /// Readable and selectable, which is what makes ⌘C and VoiceOver work, and + /// not editable, which is what makes it a transcript. + @Test("reads as a selectable transcript rather than an editor") + func isSelectableAndNotEditable() { + let textView = configured() + + #expect(textView.isEditable == false) + #expect(textView.isSelectable) + } + + /// The container follows the view's width so the text re-wraps as the window + /// is resized, and is unbounded in height so the document grows downwards + /// instead of being clipped. + @Test("tracks the view's width and grows without a height limit") + func tracksWidthAndGrowsDown() throws { + let container = try #require(configured().textContainer) + + #expect(container.widthTracksTextView) + #expect(container.size.height == CGFloat.greatestFiniteMagnitude) + // The document's margin is `textContainerInset`; this would add five more + // points inside every line fragment. + #expect(container.lineFragmentPadding == 0) + } + + /// The SwiftUI background behind the pane is the one the design calls for, and + /// AppKit's would paint over it. + @Test("draws no background of its own") + func drawsNoBackground() { + #expect(configured().drawsBackground == false) + } + + /// Contiguous layout is what gives an exact document height, and so a scroll + /// bar that does not shift as it scrolls. Non-contiguous layout is faster to + /// first paint and reports an estimate, which is the thing choosing this stack + /// was meant to avoid. + @Test("lays a TextKit 1 document out contiguously") + func laysOutContiguously() throws { + let textView = NSTextView(usingTextLayoutManager: false) + TranscriptTextView.configure(textView) + + let layout = try #require(textView.layoutManager) + #expect(layout.allowsNonContiguousLayout == false) + } +} diff --git a/apps/macos/Tests/WorkspaceModelTests.swift b/apps/macos/Tests/WorkspaceModelTests.swift new file mode 100644 index 000000000..66959c2c0 --- /dev/null +++ b/apps/macos/Tests/WorkspaceModelTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing + +@testable import JP + +/// The conversation list's state machine. +/// +/// Each load has to land in exactly one state, because the reason this is one +/// value rather than several properties is that several observed mutations in a +/// row make the list reload partway through its own update. +/// +/// Nested in `WorkspaceSuite` because each test points `JP_USER_DATA_DIR` at its +/// own directory, and that variable belongs to the whole process. +extension WorkspaceSuite { + @MainActor + @Suite("WorkspaceModel") + struct WorkspaceModelTests { + + /// Before anything is opened, the sidebar explains how to open something. + @Test("starts by pointing at the Open menu item") + func startsUnopened() { + let model = WorkspaceModel() + + guard case .unavailable(let title, _) = model.state else { + Issue.record("expected an unavailable state, got \(model.state)") + return + } + #expect(title == "No Workspace") + } + + /// An empty workspace is not a failure, and says so differently from one. + @Test("reports an empty workspace as having no conversations") + func reportsAnEmptyWorkspace() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let model = WorkspaceModel() + + await model.open(try makeWorkspace(in: sandbox)) + + guard case .unavailable(let title, _) = model.state else { + Issue.record("expected an unavailable state, got \(model.state)") + return + } + #expect(title == "No Conversations") + } + + @Test("reports a directory that is not a workspace") + func reportsANonWorkspace() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let model = WorkspaceModel() + + await model.open(sandbox.appendingPathComponent("nowhere").path) + + guard case .unavailable(let title, let detail) = model.state else { + Issue.record("expected an unavailable state, got \(model.state)") + return + } + #expect(title == "Could Not Open Workspace") + #expect(detail.hasPrefix("No workspace found")) + } + + /// Reading events needs a workspace, and asking before one is open is a + /// programming mistake worth a message rather than a crash. + @Test("refuses to read events with no workspace open") + func refusesEventsWithoutAWorkspace() async { + let model = WorkspaceModel() + + let result = await model.events(for: "17251488000") + + switch result { + case .success: + Issue.record("expected reading events with no workspace open to fail") + case .failure(let error): + #expect(error.message == "No workspace is open.") + } + } + + /// The path is recorded before the read, so a failed open still leaves the + /// model pointing at what was attempted. + @Test("records the path it was asked to open") + func recordsThePath() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let model = WorkspaceModel() + let path = try makeWorkspace(in: sandbox) + + await model.open(path) + + #expect(model.path == path) + } + + /// Opening a second workspace replaces the first rather than merging them. + @Test("replaces the open workspace") + func replacesTheOpenWorkspace() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let model = WorkspaceModel() + let first = try makeWorkspace(in: sandbox, named: "first") + let second = try makeWorkspace(in: sandbox, named: "second") + + await model.open(first) + await model.open(second) + + #expect(model.path == second) + } + } +} diff --git a/apps/macos/Tests/WorkspaceReaderTests.swift b/apps/macos/Tests/WorkspaceReaderTests.swift new file mode 100644 index 000000000..ee690334e --- /dev/null +++ b/apps/macos/Tests/WorkspaceReaderTests.swift @@ -0,0 +1,258 @@ +import Foundation +import Testing + +@testable import JP + +/// A timings payload, character for character. +/// +/// Pinned here and in `crates/jp_ffi/src/timing_tests.rs`, which asserts the +/// library produces this exact string. Nothing else checks that the two sides +/// agree on the shape: if one of these two literals is edited alone, the other +/// test is what says so. +private let timingsJSON = """ + [{"name":"storage.read","duration_ms":1.234},\ + {"name":"deserialize","duration_ms":84.219},\ + {"name":"serialize","duration_ms":3.0}] + """ + +/// What the library reports about its own work, turned into trace events. +/// +/// Decoding is pure, so these run outside ``WorkspaceSuite`` and in parallel +/// with it. +@Suite("LibraryTimings") +struct LibraryTimingsTests { + /// The nesting is the point. An event recorded with an empty span stack + /// still names `deserialize` and still carries a duration, and says nothing + /// about which piece of app work paid for it — so the whole span stack is + /// compared, not just the message. + /// + /// `3.0` comes back as `3`: `JSONEncoder` drops a trailing zero, and the + /// trace parser reads either as a number. + @Test("nests the library's spans under the app work that asked for them") + func nestsUnderTheEnclosingSpans() { + let lines = WorkspaceReader.timingLines( + Data(timingsJSON.utf8), + under: ["conversation.select", WorkspaceReader.eventsSpan], + at: "2026-08-02T11:04:12.418293Z" + ) + + #expect( + lines == [ + """ + {"timestamp":"2026-08-02T11:04:12.418293Z","level":"INFO","target":"JP.FFI",\ + "fields":{"message":"storage.read","duration_ms":1.234},\ + "spans":[{"name":"conversation.select"},{"name":"workspace.events"}]} + """, + """ + {"timestamp":"2026-08-02T11:04:12.418293Z","level":"INFO","target":"JP.FFI",\ + "fields":{"message":"deserialize","duration_ms":84.219},\ + "spans":[{"name":"conversation.select"},{"name":"workspace.events"}]} + """, + """ + {"timestamp":"2026-08-02T11:04:12.418293Z","level":"INFO","target":"JP.FFI",\ + "fields":{"message":"serialize","duration_ms":3},\ + "spans":[{"name":"conversation.select"},{"name":"workspace.events"}]} + """, + ] + ) + } + + /// A field added on the Rust side must not stop an app built before it from + /// reading the rest. + @Test("ignores a key it does not know") + func ignoresUnknownKeys() { + let lines = WorkspaceReader.timingLines( + Data(#"[{"name":"sort","duration_ms":0.5,"value_count":12}]"#.utf8), + under: ["workspace.open", WorkspaceReader.conversationsSpan], + at: "2026-08-02T11:04:12.418293Z" + ) + + #expect( + lines == [ + """ + {"timestamp":"2026-08-02T11:04:12.418293Z","level":"INFO","target":"JP.FFI",\ + "fields":{"message":"sort","duration_ms":0.5},\ + "spans":[{"name":"workspace.open"},{"name":"workspace.conversations"}]} + """ + ] + ) + } + + /// What a call that failed before doing any of the work it measures reports. + @Test("writes nothing for a call that measured nothing") + func writesNothingForAnEmptyPayload() { + #expect( + WorkspaceReader.timingLines( + Data("[]".utf8), under: ["conversation.select"], + at: "2026-08-02T11:04:12.418293Z" + ).isEmpty + ) + } + + /// Instrumentation nobody can read is not a reason to fail the read it was + /// measuring, so a payload that will not decode is dropped. + @Test("writes nothing for a payload it cannot decode") + func writesNothingForAMalformedPayload() { + #expect( + WorkspaceReader.timingLines( + Data(#"{"name":"sort"}"#.utf8), under: ["conversation.select"], + at: "2026-08-02T11:04:12.418293Z" + ).isEmpty + ) + } +} + +/// End-to-end tests across the FFI boundary: they link the Rust static library +/// and call it, so a failure here means the seam is broken rather than the Swift +/// being wrong. +/// +/// Nested in `WorkspaceSuite` because each test points `JP_USER_DATA_DIR` at its +/// own directory, and that variable belongs to the whole process. +extension WorkspaceSuite { + @Suite("WorkspaceReader") + struct WorkspaceReaderTests { + + /// The phase 2 goal in one assertion: the Rust library links, runs, and its + /// output decodes into Swift values. + @Test("opens a workspace and reads an empty conversation list") + func opensAnEmptyWorkspace() throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeWorkspace(in: sandbox) + + let reader = try WorkspaceReader(path: path) + let conversations = try reader.conversations() + + #expect(conversations.isEmpty) + } + + /// Any directory inside the workspace opens the workspace, so the app can + /// hand over whatever directory the user picked. + @Test("opens a directory inside the workspace") + func opensANestedDirectory() throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let workspace = try makeWorkspace(in: sandbox) + let nested = URL(fileURLWithPath: workspace) + .appendingPathComponent("src/nested") + try FileManager.default.createDirectory( + at: nested, withIntermediateDirectories: true) + + let reader = try WorkspaceReader(path: nested.path) + let conversations = try reader.conversations() + + #expect(conversations.isEmpty) + } + + /// The library's failure message reaches Swift through the thread-local error + /// slot, rather than being lost behind a null return. + /// + /// Assumes no workspace exists above the temporary directory. On a machine + /// where one does, the open succeeds and this fails loudly instead of passing + /// for the wrong reason. + @Test("reports a directory that is not a workspace") + func reportsANonWorkspace() throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeBareDirectory(in: sandbox, named: "not-a-workspace") + + do throws(WorkspaceError) { + let reader = try WorkspaceReader(path: path) + _ = try reader.conversations() + Issue.record("expected opening a bare directory to fail") + } catch { + #expect(error.message == "No workspace found at or above: \(path)") + } + } + + /// A conversation ID that is not a decisecond timestamp is rejected by the + /// library, not by Swift, so this proves the error crosses the boundary. + @Test("reports an unparsable conversation ID") + func reportsAnUnparsableConversationID() throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeWorkspace(in: sandbox) + + do throws(WorkspaceError) { + let reader = try WorkspaceReader(path: path) + _ = try reader.events(for: "not-an-id") + Issue.record("expected an unparsable conversation ID to fail") + } catch { + #expect(error.message.hasPrefix("invalid conversation ID:")) + } + } + + @Test("reports a conversation that is not in the workspace") + func reportsAMissingConversation() throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeWorkspace(in: sandbox) + + do throws(WorkspaceError) { + let reader = try WorkspaceReader(path: path) + _ = try reader.events(for: "17251488000") + Issue.record("expected a missing conversation to fail") + } catch { + #expect(error.message.hasPrefix("conversation not found:")) + } + } + + /// The session the app reads through returns the failure rather than + /// trapping, so a bad path shows up in the UI. + @Test("surfaces a failure through the session") + func sessionSurfacesFailures() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeBareDirectory(in: sandbox, named: "also-not-a-workspace") + + switch await WorkspaceSession.open(path: path) { + case .success: + Issue.record("expected opening a bare directory to fail") + case .failure(let error): + #expect(error.message.hasPrefix("No workspace found")) + } + } + + @Test("reads a workspace through the session") + func sessionReadsAWorkspace() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeWorkspace(in: sandbox) + + guard case .success(let session) = await WorkspaceSession.open(path: path) else { + Issue.record("expected the workspace to open") + return + } + + switch await session.readConversations() { + case .success(let conversations): + #expect(conversations.isEmpty) + case .failure(let error): + Issue.record("expected a successful read, got: \(error.message)") + } + } + + /// The whole point of holding a session open: many reads, one open. A + /// second read must not need the workspace reopened. + @Test("reads repeatedly from one open workspace") + func sessionReadsRepeatedly() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeWorkspace(in: sandbox) + + guard case .success(let session) = await WorkspaceSession.open(path: path) else { + Issue.record("expected the workspace to open") + return + } + + for _ in 0..<3 { + switch await session.readConversations() { + case .success(let conversations): + #expect(conversations.isEmpty) + case .failure(let error): + Issue.record("expected a successful read, got: \(error.message)") + } + } + } + } +} diff --git a/apps/macos/Tests/WorkspaceWindowTests.swift b/apps/macos/Tests/WorkspaceWindowTests.swift new file mode 100644 index 000000000..f96975b47 --- /dev/null +++ b/apps/macos/Tests/WorkspaceWindowTests.swift @@ -0,0 +1,154 @@ +import Foundation +import Testing + +@testable import JP + +/// The precedence a window applies when deciding which workspace to show. +/// Pure, so these touch no process state and run in parallel. +@Suite("WorkspaceWindow") +struct WorkspaceWindowTests { + private let recent = URL(fileURLWithPath: "/workspaces/recent") + + @Test("shows nothing when there is nothing to show") + func showsNothingWithoutASource() { + #expect( + WorkspaceWindow.chooseWorkspace(stored: nil, mostRecent: nil, environment: [:]) + == nil + ) + } + + @Test("falls back to the most recently opened workspace") + func fallsBackToTheMostRecent() { + let chosen = WorkspaceWindow.chooseWorkspace( + stored: nil, + mostRecent: recent, + environment: [:] + ) + + #expect(chosen == "/workspaces/recent") + } + + /// Two windows on two workspaces is the point of having windows, so a window + /// that stored a path keeps it rather than following the recents list. + @Test("prefers the window's own stored path over the recents list") + func prefersTheStoredPath() { + let chosen = WorkspaceWindow.chooseWorkspace( + stored: "/workspaces/stored", + mostRecent: recent, + environment: [:] + ) + + #expect(chosen == "/workspaces/stored") + } + + /// The regression that made `just run-app ` a no-op after the first + /// run: once a window had stored a path, the environment was never read again. + @Test("prefers JP_WORKSPACE over a stored path") + func prefersTheEnvironmentOverAStoredPath() { + let chosen = WorkspaceWindow.chooseWorkspace( + stored: "/workspaces/stored", + mostRecent: recent, + environment: ["JP_WORKSPACE": "/workspaces/named"] + ) + + #expect(chosen == "/workspaces/named") + } + + @Test("prefers JP_WORKSPACE over the recents list") + func prefersTheEnvironmentOverTheRecentsList() { + let chosen = WorkspaceWindow.chooseWorkspace( + stored: nil, + mostRecent: recent, + environment: ["JP_WORKSPACE": "/workspaces/named"] + ) + + #expect(chosen == "/workspaces/named") + } + + /// The app's own scheme sets `JP_WORKSPACE` to an empty string when no + /// workspace is configured, which must not beat a real stored path. + @Test("ignores an empty JP_WORKSPACE") + func ignoresAnEmptyEnvironmentValue() { + let chosen = WorkspaceWindow.chooseWorkspace( + stored: "/workspaces/stored", + mostRecent: recent, + environment: ["JP_WORKSPACE": ""] + ) + + #expect(chosen == "/workspaces/stored") + } + + @Test("ignores an empty stored path") + func ignoresAnEmptyStoredPath() { + let chosen = WorkspaceWindow.chooseWorkspace( + stored: "", + mostRecent: recent, + environment: [:] + ) + + #expect(chosen == "/workspaces/recent") + } +} + +/// What the focused window offers the menu bar. +/// +/// The equality is the whole of it, and it carries more weight than a reader +/// would guess: see ``WorkspaceActions`` for what a value that differs on every +/// render does to the app. +@Suite("WorkspaceActions") +struct WorkspaceActionsTests { + private func actions( + windowID: UUID, + hasSelection: Bool = false, + isSidebarVisible: Bool = true + ) -> WorkspaceActions { + WorkspaceActions( + windowID: windowID, + hasSelection: hasSelection, + isSidebarVisible: isSidebarVisible, + choose: {}, + open: { _ in }, + copyLinks: {}, + toggleSidebar: {} + ) + } + + /// The one that matters. A window republishes this on every render with + /// fresh closures, and closures never compare equal, so comparing them would + /// invalidate the whole scene continuously. + @Test("a republished value from the same window compares equal") + func republishingIsNotAChange() { + let window = UUID() + + #expect(actions(windowID: window) == actions(windowID: window)) + } + + @Test("a value from another window differs") + func anotherWindowDiffers() { + #expect(actions(windowID: UUID()) != actions(windowID: UUID())) + } + + /// A menu item conditioned on the selection has to be re-evaluated when the + /// selection appears, and equality is the only thing that asks for it. + @Test("gaining a selection is a change") + func gainingASelectionIsAChange() { + let window = UUID() + + #expect( + actions(windowID: window, hasSelection: false) + != actions(windowID: window, hasSelection: true) + ) + } + + /// The View menu's item is titled from this, so hiding the sidebar has to be + /// a change or the item keeps saying Hide when it means Show. + @Test("hiding the sidebar is a change") + func hidingTheSidebarIsAChange() { + let window = UUID() + + #expect( + actions(windowID: window, isSidebarVisible: true) + != actions(windowID: window, isSidebarVisible: false) + ) + } +} diff --git a/apps/macos/project.yml b/apps/macos/project.yml new file mode 100644 index 000000000..9105ade0b --- /dev/null +++ b/apps/macos/project.yml @@ -0,0 +1,158 @@ +name: JP + +options: + bundleIdPrefix: computer.jp + createIntermediateGroups: true + # The oldest macOS to support, and so the newest SwiftUI available: raise this + # before reaching for an API that needs a later release. + deploymentTarget: + macOS: "15.7" + +# Applied to every target. The Rust side denies warnings and runs in the +# strictest mode its toolchain offers; these settings hold Swift to the same bar. +settings: + base: + # Names the language mode, not the toolchain: `6.0` is the newest value the + # setting accepts, and a 6.3 toolchain still contributes its own compiler and + # SDK. Turns on complete concurrency checking, data-race safety, and the + # Swift 6 breaking changes. + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + + # A warning that never fails a build is a warning nobody fixes. + SWIFT_TREAT_WARNINGS_AS_ERRORS: YES + GCC_TREAT_WARNINGS_AS_ERRORS: YES + + RUN_CLANG_STATIC_ANALYZER: YES + CLANG_STATIC_ANALYZER_MODE: deep + + # `ExistentialAny` requires existentials to be spelled `any P`, so a witness + # table lookup is visible at the use site rather than inferred. + OTHER_SWIFT_FLAGS: -enable-upcoming-feature ExistentialAny + + # The pre-build script shells out to cargo, which writes outside the derived + # data directory. Script sandboxing (on by default since Xcode 15) denies + # that and fails the phase. + ENABLE_USER_SCRIPT_SANDBOXING: NO + + # Both targets need to resolve `jp_ffi.h`, not just the one that names it as + # a bridging header: `@testable import JP` loads the app's swiftmodule, which + # re-reads the bridging header through the importing target's search paths. + # + # `just build-ffi` stages the library and header here. It is not cargo's + # target directory, which is redirectable and can sit outside the checkout; + # these paths have to be static, so the build stages into a fixed one. + HEADER_SEARCH_PATHS: + - $(SRCROOT)/.build/$(CARGO_PROFILE)/include + + # Xcode's configuration names are capitalized; cargo's profile directories are + # not. Set per-project because the header search path above interpolates it. + configs: + Debug: + CARGO_PROFILE: debug + # Set explicitly rather than relying on a generator default, because a + # test seam is gated on it: `DebugState.pasteboard` reads an environment + # variable only under `#if DEBUG`, and a Debug build that quietly lost + # this flag would compile that read out and leave the UI tests copying + # into the developer's clipboard. + SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG + # A Debug build otherwise leaves its debug info scattered across the + # object files, and `xct2cli` symbolicates a trace from a dSYM bundle. + # Costs a dsymutil pass on every link. + DEBUG_INFORMATION_FORMAT: dwarf-with-dsym + # Xcode otherwise splits a Debug build into a launcher stub at + # `JP.app/Contents/MacOS/JP` and a `JP.debug.dylib` holding the actual + # code. A profiler pointed at the bundle's executable then reads a stub + # whose UUID matches nothing in the trace, and every frame in our own + # code comes back as a bare address. Off, so the binary carrying the + # code is the binary being profiled. + ENABLE_DEBUG_DYLIB: NO + Release: + CARGO_PROFILE: release + +targets: + JP: + type: application + platform: macOS + sources: + - path: Sources + settings: + base: + PRODUCT_NAME: JP + PRODUCT_BUNDLE_IDENTIFIER: computer.jp.jean-pierre + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + GENERATE_INFOPLIST_FILE: YES + + # Swift reaches the C entry points through this header. + SWIFT_OBJC_BRIDGING_HEADER: Sources/Bridging/JPFFI-Bridging-Header.h + + # Only the app links the library. The test bundle is loaded into the app + # process, so its symbols resolve through the host. + LIBRARY_SEARCH_PATHS: + - $(SRCROOT)/.build/$(CARGO_PROFILE) + OTHER_LDFLAGS: + - -ljp_ffi + # Rust's `iana-time-zone`, reached through `chrono`, resolves the local + # time zone through CoreFoundation. + - -framework + - CoreFoundation + + # Ad-hoc signing keeps a local run from needing a development team. The + # app is not sandboxed, so it can read a workspace anywhere on disk. + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "-" + + preBuildScripts: + - name: Build jp_ffi + # Cargo decides what needs rebuilding. Letting Xcode skip the phase on + # its own output timestamps would leave a stale library linked after a + # Rust change. + # + # This keeps a build started from Xcode's UI honest, but it is not the + # only guard: Xcode scans the bridging header while planning the build, + # which happens before any script phase runs, so the header must already + # exist. `just build-app` and the `swift_*` tools build it up front. + basedOnDependencyAnalysis: false + script: | + set -eu + cd "$SRCROOT/../.." + just build-ffi "$CARGO_PROFILE" + + JPTests: + type: bundle.unit-test + platform: macOS + sources: + - path: Tests + dependencies: + - target: JP + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: computer.jp.jean-pierre.tests + GENERATE_INFOPLIST_FILE: YES + # The tests exercise types internal to the app, so they run hosted by the + # app bundle rather than standalone. + TEST_HOST: $(BUILT_PRODUCTS_DIR)/JP.app/Contents/MacOS/JP + BUNDLE_LOADER: $(TEST_HOST) + +schemes: + JP: + build: + targets: + JP: all + JPTests: [test] + run: + config: Debug + # The workspace to open. Phase 2 has no file chooser, so the path comes + # from here. + environmentVariables: + JP_WORKSPACE: "" + test: + config: Debug + # Coverage is off deliberately. It puts `-profile-generate` into the build, + # which instruments every function and drops a `default.profraw` beside the + # binary on each run — a cost paid on every `just run-app` for data nothing + # currently reads. Turn it back on alongside something that reports it. + gatherCoverageData: false + targets: + - JPTests diff --git a/justfile b/justfile index 61787d0cf..9c5e50bd0 100644 --- a/justfile +++ b/justfile @@ -181,6 +181,123 @@ build-ffi PROFILE="debug": (_install "cbindgen@" + cbindgen_version) echo "library: $out/libjp_ffi.a" >&2 echo "header: $out/include/jp_ffi.h" >&2 +# Generate the macOS app's Xcode project from `apps/macos/project.yml`. +# +# The project file is generated rather than committed, so `project.yml` stays the +# reviewable source of truth for targets, build settings, and the Rust build +# phase. +[group('build')] +[macos] +gen-app: + #!/usr/bin/env sh + set -eu + + if ! which xcodegen >/dev/null 2>&1; then + echo "xcodegen not found. Install it with: brew install xcodegen" >&2 + exit 1 + fi + + xcodegen generate --spec apps/macos/project.yml --project apps/macos + +# Build the macOS app. +# +# The library and its header are built first, not left to the project's own build +# phase: Xcode scans the bridging header while planning the build, before any +# script phase runs. +[group('build')] +[macos] +build-app CONFIG="Debug": gen-app + #!/usr/bin/env sh + set -eu + + if [ "{{CONFIG}}" = "Release" ]; then + just build-ffi release + else + just build-ffi debug + fi + + xcodebuild build -project apps/macos/JP.xcodeproj -scheme JP \ + -configuration {{CONFIG}} -destination platform=macOS -quiet + +# Build and launch the macOS app, with its output attached to this terminal. +# +# WORKSPACE is the workspace to open, defaulting to this checkout. The app has a +# File ▸ Open Workspace menu item too; this just saves a step. +# +# Runs in the foreground so `tracing` output and crashes are visible, and Ctrl-C +# quits. Use `open` on the printed bundle path instead to launch it detached. +[group('build')] +[macos] +run-app WORKSPACE=justfile_directory(): build-app + #!/usr/bin/env sh + set -eu + + if ! which jq >/dev/null 2>&1; then + echo "jq not found. Install it with: brew install jq" >&2 + exit 1 + fi + + # Ask Xcode where it put the bundle. The derived data directory is keyed by a + # hash of the project path, so there is no path to hardcode. + app=$(xcodebuild -project apps/macos/JP.xcodeproj -scheme JP -configuration Debug \ + -showBuildSettings -json | + jq -r 'first(.[] | select(.target == "JP") | .buildSettings) | + "\(.BUILT_PRODUCTS_DIR)/\(.FULL_PRODUCT_NAME)"') + + if [ ! -d "$app" ]; then + echo "Could not locate the built app (looked for '$app')" >&2 + exit 1 + fi + + echo "bundle: $app" >&2 + echo "workspace: {{WORKSPACE}}" >&2 + + JP_WORKSPACE="{{WORKSPACE}}" "$app/Contents/MacOS/JP" + +# Build and launch the macOS app through LaunchServices, detached. +# +# `run-app` execs the binary inside the bundle directly, which is convenient for +# watching output but is not how macOS launches an app. Some AppKit behaviour +# depends on the app being launched and registered normally, so this is the one to +# reach for when the app misbehaves in ways the code does not explain. +# +# Output goes to the system log rather than this terminal, and the workspace comes +# from the recents list rather than an environment variable. +[group('build')] +[macos] +open-app: build-app + #!/usr/bin/env sh + set -eu + + if ! which jq >/dev/null 2>&1; then + echo "jq not found. Install it with: brew install jq" >&2 + exit 1 + fi + + app=$(xcodebuild -project apps/macos/JP.xcodeproj -scheme JP -configuration Debug \ + -showBuildSettings -json | + jq -r 'first(.[] | select(.target == "JP") | .buildSettings) | + "\(.BUILT_PRODUCTS_DIR)/\(.FULL_PRODUCT_NAME)"') + + if [ ! -d "$app" ]; then + echo "Could not locate the built app (looked for '$app')" >&2 + exit 1 + fi + + echo "bundle: $app" >&2 + open "$app" + +# Run the macOS app's unit tests. +# +# The UI tests are excluded: they launch the app and drive it through the screen, +# so they cannot run alongside anything else using the machine. `test-app-ui` +# runs those. +[group('test')] +[macos] +test-app: gen-app (build-ffi "debug") + xcodebuild test -project apps/macos/JP.xcodeproj -scheme JP \ + -destination platform=macOS -only-testing:JPTests -quiet + [group('profile')] [positional-arguments] profile-heap *ARGS: From c189b4ee26b5d59e282219647ea3ec2851907108 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Wed, 19 Aug 2026 08:39:59 +0200 Subject: [PATCH 6/8] feat(drive): Add the `jpdrive` accessibility driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debugging the macOS app from a conversation needs a way to read what is actually on screen and act on it. Screenshots answer "what does it look like" but not "what is the button called, is it enabled, does the menu item exist" — and none of that is reachable from the app's own test bundle, which runs inside the process and cannot see menu enablement, the pasteboard, or a relaunch. `jpdrive` reads and drives another application through its accessibility tree, and writes one JSON document to stdout either way: a result, or an error with a non-zero exit status. It is what the `debug_app_*` tools shell out to. A standalone SwiftPM package rather than a target in the app's Xcode project, so the binary lands at a predictable path and nothing has to search derived data for it. The logic lives in a `DriveKit` library with a one-line executable on top, because SwiftPM cannot cleanly test an executable target and the tree traversal is where the bugs are. The tests run against a fake accessibility tree, so they need no running app and no accessibility grant. The package mirrors the app's strictness: Swift 6 language mode, `ExistentialAny`, and warnings as errors. SwiftPM 6.0 has no first-class setting for the last of those, so it goes through `unsafeFlags`, which is rejected only for a package consumed as a dependency — this one never is. Accessibility is gated by TCC, and a grant given to a terminal does not obviously reach a tool that terminal started. `just drive-doctor` reports whether the calling process may read another app's tree, so that question is answered by running it under each host rather than guessed at. Signed-off-by: Jean Mertz --- apps/macos/Tools/jpdrive/Package.swift | 34 + apps/macos/Tools/jpdrive/README.md | 298 ++++ .../jpdrive/Sources/DriveKit/AXElement.swift | 359 +++++ .../Sources/DriveKit/AXErrorName.swift | 24 + .../Tools/jpdrive/Sources/DriveKit/Act.swift | 1218 +++++++++++++++++ .../jpdrive/Sources/DriveKit/Ambient.swift | 80 ++ .../jpdrive/Sources/DriveKit/Arguments.swift | 425 ++++++ .../jpdrive/Sources/DriveKit/Doctor.swift | 95 ++ .../jpdrive/Sources/DriveKit/DriveError.swift | 81 ++ .../jpdrive/Sources/DriveKit/Driver.swift | 68 + .../Tools/jpdrive/Sources/DriveKit/Dump.swift | 119 ++ .../jpdrive/Sources/DriveKit/Duration.swift | 15 + .../jpdrive/Sources/DriveKit/Element.swift | 186 +++ .../Tools/jpdrive/Sources/DriveKit/Menu.swift | 52 + .../jpdrive/Sources/DriveKit/Output.swift | 44 + .../jpdrive/Sources/DriveKit/Pixels.swift | 252 ++++ .../Sources/DriveKit/ProcessTable.swift | 58 + .../Tools/jpdrive/Sources/DriveKit/Tree.swift | 183 +++ .../jpdrive/Sources/DriveKit/WindowIDs.swift | 133 ++ .../jpdrive/Sources/DriveKit/Windows.swift | 74 + .../Tools/jpdrive/Sources/jpdrive/main.swift | 3 + .../Tests/DriveKitTests/ActTests.swift | 249 ++++ .../Tests/DriveKitTests/ArgumentsTests.swift | 201 +++ .../Tests/DriveKitTests/ClickTests.swift | 115 ++ .../Tests/DriveKitTests/DragTests.swift | 229 ++++ .../Tests/DriveKitTests/FakeElement.swift | 173 +++ .../Tests/DriveKitTests/FakePoster.swift | 35 + .../Tests/DriveKitTests/MenuTests.swift | 272 ++++ .../Tests/DriveKitTests/PixelsTests.swift | 231 ++++ .../Tests/DriveKitTests/ResizeTests.swift | 103 ++ .../Tests/DriveKitTests/TreeTests.swift | 164 +++ .../Tests/DriveKitTests/TypeTests.swift | 186 +++ .../Tests/DriveKitTests/WaitForTests.swift | 137 ++ .../Tests/DriveKitTests/WindowIDsTests.swift | 129 ++ .../Tests/DriveKitTests/WindowsTests.swift | 61 + justfile | 46 + 36 files changed, 6132 insertions(+) create mode 100644 apps/macos/Tools/jpdrive/Package.swift create mode 100644 apps/macos/Tools/jpdrive/README.md create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/AXElement.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/AXErrorName.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Act.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Ambient.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Arguments.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Doctor.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/DriveError.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Driver.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Dump.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Duration.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Element.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Menu.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Output.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Pixels.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/ProcessTable.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Tree.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/WindowIDs.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/DriveKit/Windows.swift create mode 100644 apps/macos/Tools/jpdrive/Sources/jpdrive/main.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/ActTests.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/ArgumentsTests.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/ClickTests.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/DragTests.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakeElement.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakePoster.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/MenuTests.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/PixelsTests.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/ResizeTests.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/TreeTests.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/TypeTests.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/WaitForTests.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowIDsTests.swift create mode 100644 apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowsTests.swift diff --git a/apps/macos/Tools/jpdrive/Package.swift b/apps/macos/Tools/jpdrive/Package.swift new file mode 100644 index 000000000..5e3cee840 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +// Mirrors the app's `project.yml`: an existential is spelled `any P`, and a +// warning that never fails a build is a warning nobody fixes. +// +// SwiftPM 6.0 has no first-class setting for warnings-as-errors, and +// `unsafeFlags` is rejected only for a package consumed as a dependency, which +// this one never is. +let strict: [SwiftSetting] = [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .unsafeFlags(["-warnings-as-errors"]), +] + +let package = Package( + name: "jpdrive", + platforms: [.macOS(.v15)], + products: [ + .executable(name: "jpdrive", targets: ["jpdrive"]) + ], + targets: [ + // The driver's logic, in a library so it can be tested. SwiftPM cannot + // cleanly test an executable target, and the traversal is where the bugs + // are. + .target(name: "DriveKit", swiftSettings: strict), + + // One line, calling into the library. + .executableTarget(name: "jpdrive", dependencies: ["DriveKit"], swiftSettings: strict), + + .testTarget(name: "DriveKitTests", dependencies: ["DriveKit"], swiftSettings: strict), + ] +) diff --git a/apps/macos/Tools/jpdrive/README.md b/apps/macos/Tools/jpdrive/README.md new file mode 100644 index 000000000..58206ee13 --- /dev/null +++ b/apps/macos/Tools/jpdrive/README.md @@ -0,0 +1,298 @@ +# jpdrive + +Reads and acts on a running macOS app's accessibility tree, speaking JSON. The +`debug_app_*` tools shell out to it; the Rust side stays the presenter, parsing +the JSON and rendering markdown. + +Swift rather than Rust because `AXUIElement` is CoreFoundation-shaped: ordinary +code here, unsafe bindings or a 784-download crate there. + +External rather than an in-app automation socket, deliberately. Driving through +`AXUIElement` means a broken accessibility tree breaks the tooling, which is the +pressure that keeps the app's accessibility honest. + +## Build + +```sh +just build-drive +``` + +The binary lands at `.build/release/jpdrive` under this directory. + +## The TCC question + +Everything downstream depends on one unknown: does a binary launched as a child +of `just serve-tools` inherit the Accessibility grant given to the terminal? + +macOS attributes TCC to the *responsible process*, which for a command-line tool +is normally the terminal rather than the tool. That is the same mechanism behind +the `sample(1)` note in `.config/jp/tools/src/debug_jp/profile_sampling.rs` about +granting Terminal *Developer Tools*. Apple documents neither the algorithm nor +its stability, so the answer has to be measured. + +`jpdrive doctor` measures it. Run it three ways, with Accessibility granted to +the terminal application and the app running: + +Check the target first. An empty `pgrep` means the app is not running, and a +run without a target reports the trust flag alone, which is the half of the +answer that can be wrong: + +```sh +pgrep -f JP.app # must print exactly one pid +``` + +```sh +# 1. Directly from the terminal. +.build/release/jpdrive doctor --pid $(pgrep -f JP.app) + +# 2. Through just, which adds the process layer the tools will run under. +just drive-doctor $(pgrep -f JP.app) +``` + +The third case, a child of `jp-tools` under `just serve-tools`, needs a tool that +shells out to the driver. Reaching it means writing the first `debug_app_*` tool, +which is why cases 1 and 2 come first: if the grant already fails at case 2, +nothing is learned by going further. + +Compare `trusted` and `probe.axError` across the runs. `trusted: true` with a +window count means the grant inherits. `trusted: false`, or `api_disabled` / +`cannot_complete` from the probe, means it does not, and the driver needs its own +signed bundle or its own grant. + +The report lists the ancestor chain, so a `false` says which processes were +candidates for holding the grant. + +The check never prompts. `AXIsProcessTrustedWithOptions` with +`kAXTrustedCheckOptionPrompt` would raise the system dialog and change the state +being measured. + +### Result + +**The grant inherits.** With Accessibility granted to Ghostty, case 2 reports +`trusted: true` and a window count from a chain of six: + +``` +ghostty → login → fish → just → sh → jpdrive +``` + +So `tree`, `windows`, `menu`, and `act` need no signed bundle and no grant of +their own. They can assume the terminal's. Case 3, a child of `jp-tools` under +`just serve-tools`, adds one more process of the same kind and is still +unmeasured. + +Observations across the runs, on macOS with Ghostty as the terminal: + +- Process depth is not the variable. Run directly from the shell (chain of four, + up to `ghostty`) and through `just` (chain of six, adding `sh` and `just`), the + report is identical. Whatever governs the grant, it is not the number of + processes between the terminal and the driver. +- The trust flag and the probe agree. `trusted: false` came with + `ax_error: api_disabled` from a real read against a running app, which is what + the accessibility API returns to an untrusted caller; `trusted: true` came with + a window count. No case has been seen where the two disagree. +- Untested: a terminal instance started *before* the grant. The `false` runs and + the `true` run may differ by the grant alone, by a relaunch, or by both, so + "the grant is not visible to this terminal instance" is not yet ruled out as a + separate failure mode. + +## Screen Recording is a second grant + +`windowid` answers the window server rather than the accessibility API, and the +two are governed by different TCC grants. Enumerating windows needs neither, so +the command works with nothing granted at all; reading a window's *title*, and +capturing its content with `screencapture -l`, need Screen Recording. + +That is why the report pairs the list with a `screen_recording` flag rather than +refusing outright. Missing the grant, a capture succeeds and returns the desktop +where the window should be, so the caller has to know before it writes a file. +An untitled window in the list is the same fact seen from the other side. + +The pane is System Settings ▸ Privacy & Security ▸ Screen & System Audio +Recording, and as with Accessibility it is the terminal application that needs +it, not the driver. + +### Result + +**The grant inherits.** Measured with Ghostty as the terminal, the driver run as +a child of `jp-tools` under `just serve-tools`: before the grant, +`screen_recording` came back `false` and `debug_app_screenshot` refused; after +granting Screen Recording to Ghostty and restarting it, the same call captured +the window. + +So the flag is worth trusting, and this grant reaches a driver six processes +deep from the terminal, same as Accessibility does. + +Untested: whether the restart was necessary. The grant and the restart happened +together, so nothing here separates them. + +## What the sidebar looks like through accessibility + +SwiftUI's `.accessibilityIdentifier` does not land on the element that owns +behaviour. For a `List` row it lands two levels below it: + +``` +AXOutline AXIdentifier: sidebar.list AXRows: 1065, AXVisibleRows: 9 + AXRow AXSelected settable: true + AXCell AXSelected settable: false, AXScrollToVisible settable: true + AXUnknown AXIdentifier: sidebar.row. + AXAttributedDescription: ", <n> events" + no actions, no children +``` + +So addressing an element and acting on it are two different steps. The identified +element has no actions at all: no `AXPress`, nothing. Selecting a row means +walking up to the `AXRow` and writing `AXSelected`. + +That write is preferable to a synthesized click for a reason beyond determinism. +Every row exists as an accessibility element, but only nine are on screen: the +outline's frame is 41658pt tall against a 398pt viewport. A click at +`AXActivationPoint` would miss an off-screen row, or land on whichever row +occupies those coordinates instead. An `AXSelected` write is independent of +scroll position. + +`AXScrollToVisible` appears as a settable *attribute* on a sidebar cell and as an +*action* on a transcript event, so scrolling has to try both forms. + +The sidebar materialises every row; the transcript does not. Only one +`transcript.event.*` element exists at a time, so an identifier that names an +unrendered event cannot be waited for, only scrolled to. + +Writing `AXSelected` on a row 690 places down a thousand-row list selects it and +brings it into view, so selecting a row needs no scrolling step of its own. The +transcript still does. + +### The identified element cannot be walked upwards + +The `AXUnknown` carrying the identifier reports no `AXParent`, and no +`AXTopLevelUIElement` either, unlike the cell and row above it. Climbing from it +arrives nowhere. + +So resolving an identifier means keeping the chain the search descended through, +not finding the element and navigating from it afterwards. Anything that acts on +an ancestor of an identified element depends on this. + +### Cost + +An accessibility round-trip to this app costs roughly 3ms, and that number sets +every other budget: + +- Reading the first few rows under `sidebar.` takes 250ms. +- Finding one row 690 places down takes 5.8s, because the search reads about two + thousand elements to get there and cannot prune on the way: every identifier in + the sidebar sits on a leaf. + +Hence the batched reads and the match budget. Anything that polls should resolve +an element once and re-read that reference, rather than searching each time. + +## Acting on an element + +Each step names exactly one mechanism, because the mechanism depends on what the +element is and guessing hides regressions: + +| step | addressed by | mechanism | +| --- | --- | --- | +| `select` | identifier | write `AXSelected` on the nearest ancestor accepting it | +| `press` | identifier | `AXPress` on the element itself | +| `type` | identifier | write `AXValue`, then `AXConfirm` | +| `perform` | identifier | a named action, for the verbs with no step of their own | +| `menu` | titled path | `AXPress` on the item the path resolves to | +| `click` | identifier | synthesized mouse event at `AXActivationPoint` | + +`press` and `menu` end in the same call and are not redundant: they differ in what +they address by, and that is what a test pins. `closeAll:` is an `AppKit` selector +name that survives the item moving to another menu, so a script keyed on it cannot +notice the menu bar being rearranged. `["File", "Close All"]` names the structure +the user sees, and a path that stops resolving reports how far it got and what that +level holds instead — which is the assertion failure a layout test wants to read. + +A step that names the wrong mechanism fails and says which actions the element +does accept. There is no fallback chain: if a sidebar row stopped accepting +`AXSelected`, a driver that quietly fell back to a synthesized click would keep +every script green while the app's accessibility rotted, which is the failure this +tool exists to prevent. + +`select` and `type` read the attribute back afterwards, because a write can be +accepted and discarded. `press` cannot: nothing observable says a button did +anything, so its result reports no confirmation rather than claiming one. + +### A menu step has to bring the app forward + +`menu` writes `AXFrontmost` on the application and waits for it to take, which +makes it the one step that takes focus from whatever had it. + +Without it almost nothing in the menu bar can be pressed. AppKit disables every +item that acts on the front window or the responder chain while the application +is in the background, and against a driven instance that is most of the bar: +`Close`, `Copy`, `Select All`, `Show Sidebar`, and every `SwiftUI` command +reading a `@FocusedValue` all report `AXEnabled: 0`. `New Window` and `Close +All` do not, which is what makes the difference easy to miss — a first menu step +against an app-level item works, and the next one silently does nothing. + +So the item's enabled state is checked before it is pressed, rather than +trusting `AXPress` to report a refusal. A disabled item accepts the press and +answers success. + +An element that reports no `AXEnabled` at all is not disabled. Plenty carry no +such attribute, and reading its absence as a refusal would reject them all. + +### Typing writes the value, and then has to commit it + +`type` writes `AXValue` and performs `AXConfirm`. Both are needed, and the second +one is the part that was not obvious. + +Writing `AXValue` on a `SwiftUI` `TextField` changes the text the field displays +and leaves the binding behind it untouched. Measured against the conversation +filter: after the write the field read back `"accessibility"` and the list still +showed all 1,066 rows. Deleting one character by hand then filtered on +`"accessibilit"` — the keystroke made the binding resync from whatever the field +held by then. So a `type` that only wrote the value would report success while the +application carried on as though nothing had been typed. + +`AXConfirm` commits through the path the binding observes. A field advertising no +confirm action is not a failure — some publish every change as it happens — so the +result reports `committed` separately from `confirmed`: the text being in the field +and the application having seen it are different facts. + +Synthesizing key events was rejected on three counts: the events go wherever focus +is, so a window activating mid-sequence types into it instead; posting them fast +enough to be useful means pauses between characters, which makes the step flaky +rather than deterministic; and event posting is global process state, so it could +not sit behind the element abstraction the rest of the driver is tested through. + +The remaining cost is that per-character behaviour never runs. A field that +validates each keystroke, or completes as you type, sees one change rather than a +dozen. + +### Clicking is the last resort + +`click` raises the element's window and posts a mouse event at its +`AXActivationPoint`. It is the only step whose effect is not addressed to an +element: the event goes to whatever occupies that screen coordinate, which is why +the window is raised first and why an occluding window from another application +will still swallow it. + +An element reporting no activation point is refused rather than clicked at the +origin. A sidebar row is exactly that case, and it wants `select`. + +Posting is behind an `EventPoster`, so where the driver aimed can be asserted in a +test even though where the event lands cannot. + +### Apple Events are a separate pathway, and that one does not inherit + +Reading the same tree through AppleScript fails from the terminal the driver +succeeds from: + +``` +System Events got an error: osascript is not allowed assistive access. (-1719) +``` + +Two different checks. `AXIsProcessTrusted`, which the driver calls, resolves to +the responsible process and finds the terminal. `System Events` requires the +calling binary itself to be listed, and the calling binary is `/usr/bin/osascript` +— shared by everything on the machine, so granting it grants far more than the +driver needs. + +This is the second reason the driver is a binary of its own rather than a shell +script over `osascript`, alongside the one at the top of this file. It also means +AppleScript is not a fallback when the driver is missing a verb: the verb has to +be added here. diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/AXElement.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/AXElement.swift new file mode 100644 index 000000000..4ee15b376 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/AXElement.swift @@ -0,0 +1,359 @@ +import ApplicationServices +import Foundation + +/// An element of a running application's accessibility tree. +/// +/// Every method here is one or more synchronous round-trips to the target's main +/// thread. That cost dominates everything the driver does, so callers batch reads +/// with ``values(_:)`` rather than reading attributes one at a time, and hold onto +/// an element they will read again instead of walking to it twice. +/// +/// A reference stays valid while the underlying element lives. Once it is gone, +/// reads answer `invalid_ui_element` rather than crashing. +struct AXElement { + let element: AXUIElement + + /// The root element of the application owning `pid`. + /// + /// Succeeds whether or not the process exists; the first read is what fails. + static func application(pid: pid_t) -> AXElement { + return AXElement(element: AXUIElementCreateApplication(pid)) + } + + /// `AXRole`, or `nil` when the element does not report one. + var role: String? { + return read(kAXRoleAttribute).flatMap { $0 as? String } + } + + /// `AXIdentifier`, or `nil` when the element carries none. + /// + /// SwiftUI's `.accessibilityIdentifier` surfaces here, but it also composites + /// with identifiers the framework generates itself, so a value like + /// `"workspace-AppWindow-1, SidebarNavigationSplitView"` is possible. + var identifier: String? { + return read(kAXIdentifierAttribute).flatMap { $0 as? String } + } + + /// The element's human-readable label. + /// + /// Tries `AXAttributedDescription`, then `AXDescription`, then `AXTitle`. + /// SwiftUI populates the first of those for list rows and leaves the others + /// empty, while AppKit controls tend to do the reverse. + var label: String? { + if let attributed = read(Self.attributedDescription) as? NSAttributedString { + return attributed.string + } + + for name in [kAXDescriptionAttribute, kAXTitleAttribute] { + guard let text = read(name).flatMap({ $0 as? String }), !text.isEmpty else { + continue + } + return text + } + + return nil + } + + /// `AXAttributedDescription`, which has no constant in the SDK headers. + static let attributedDescription = "AXAttributedDescription" + + /// `AXActivationPoint`, which has no constant in the SDK headers. + /// + /// Where the element says a click on it belongs, in screen coordinates. Not + /// always the middle of its frame. + static let activationPoint = "AXActivationPoint" + + /// The element's children, or an empty array when it has none. + /// + /// A round-trip of its own. A walk should take children from ``read(_:)``, + /// which fetches them alongside everything else it needs. + var children: [AXElement] { + guard let value = read(kAXChildrenAttribute), let raw = value as? [AXUIElement] else { + return [] + } + return raw.map { AXElement(element: $0) } + } + + /// Actions the element accepts, such as `AXPress`. + var actions: [String] { + var names: CFArray? + guard AXUIElementCopyActionNames(element, &names) == .success, + let names = names as? [String] + else { + return [] + } + return names + } + + /// Every attribute name the element advertises. + func names() -> [String] { + var names: CFArray? + guard AXUIElementCopyAttributeNames(element, &names) == .success, + let names = names as? [String] + else { + return [] + } + return names + } + + /// Read one attribute, or `nil` when the read fails or the value is absent. + /// + /// Use ``values(_:)`` when reading more than one: this costs a round-trip per + /// call, which is what makes a naive tree walk take seconds. + func read(_ name: String) -> CFTypeRef? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success + else { + return nil + } + guard let value, CFGetTypeID(value) != CFNullGetTypeID() else { return nil } + return value + } + + /// Read several attributes in one round-trip. + /// + /// Results are positional and the same count as `names`. An attribute that + /// could not be read arrives as CoreFoundation's null or as an `AXValue` + /// boxing the error, both of which ``text(_:)`` reports rather than discards. + func values(_ names: [String]) -> [CFTypeRef?] { + guard !names.isEmpty else { return [] } + + var raw: CFArray? + let status = AXUIElementCopyMultipleAttributeValues( + element, + names as CFArray, + AXCopyMultipleAttributeOptions(), + &raw + ) + + guard status == .success, + let values = raw as? [CFTypeRef], + values.count == names.count + else { + return Array(repeating: nil, count: names.count) + } + + return values + } + + /// Whether `name` can be written on this element. + /// + /// A failed query reports as not settable: the accessibility API answers this + /// for every attribute it advertises, so a failure means the element is gone + /// or the attribute is not really there. + func isSettable(_ name: String) -> Bool { + var settable = DarwinBoolean(false) + guard AXUIElementIsAttributeSettable(element, name as CFString, &settable) == .success + else { + return false + } + return settable.boolValue + } + + /// Perform `action`, returning the API's own status. + func perform(_ action: String) -> AXError { + return AXUIElementPerformAction(element, action as CFString) + } +} + +extension AXElement: Element { + /// Read the named attributes and the element's children in one round-trip. + /// + /// Children come back in the same batch as everything else: asking for them + /// separately would add a hop per element, and every walk asks for them. + func read(_ names: [String]) -> Reading<AXElement> { + let values = self.values(names + [kAXChildrenAttribute]) + + let children = (values.last.flatMap { $0 } as? [AXUIElement] ?? []) + .map { AXElement(element: $0) } + + return Reading( + text: values.dropLast().map(Self.optionalText), + children: children + ) + } + + /// Read a boolean attribute. + /// + /// `CFBoolean` bridges to `NSNumber` rather than to `Bool`, so a direct cast + /// answers `nil` for a perfectly good `0` or `1`. + func flag(_ name: String) -> Bool? { + guard let value = read(name) as? NSNumber else { return nil } + return value.boolValue + } + + /// Write a boolean attribute, answering the API's own status. + func setFlag(_ name: String, _ value: Bool) -> AXError { + return AXUIElementSetAttributeValue( + element, + name as CFString, + value ? kCFBooleanTrue : kCFBooleanFalse + ) + } + + /// The point held in an attribute, in screen coordinates. + func point(_ name: String) -> CGPoint? { + guard let value = read(name), CFGetTypeID(value) == AXValueGetTypeID() else { + return nil + } + + let boxed = unsafeDowncast(value, to: AXValue.self) + guard AXValueGetType(boxed) == .cgPoint else { return nil } + + var point = CGPoint.zero + guard AXValueGetValue(boxed, .cgPoint, &point) else { return nil } + + return point + } + + /// The size held in an attribute, in points. + func size(_ name: String) -> CGSize? { + guard let value = read(name), CFGetTypeID(value) == AXValueGetTypeID() else { + return nil + } + + let boxed = unsafeDowncast(value, to: AXValue.self) + guard AXValueGetType(boxed) == .cgSize else { return nil } + + var size = CGSize.zero + guard AXValueGetValue(boxed, .cgSize, &size) else { return nil } + + return size + } + + /// Write a size attribute, answering the API's own status. + /// + /// The value has to be boxed in an `AXValue`: the API takes `CFTypeRef` and a + /// bare `CGSize` is not one, so passing it any other way fails the write with + /// no indication of why. + func setSize(_ name: String, _ value: CGSize) -> AXError { + var size = value + guard let boxed = AXValueCreate(.cgSize, &size) else { + return .failure + } + + return AXUIElementSetAttributeValue(element, name as CFString, boxed) + } + + /// Write a string attribute, answering the API's own status. + func setText(_ name: String, _ value: String) -> AXError { + return AXUIElementSetAttributeValue(element, name as CFString, value as CFString) + } + + /// The elements held in an attribute. + func elements(_ name: String) -> [AXElement] { + guard let value = read(name) else { return [] } + + if let raw = value as? [AXUIElement] { + return raw.map { AXElement(element: $0) } + } + + guard CFGetTypeID(value) == AXUIElementGetTypeID() else { return [] } + return [AXElement(element: unsafeDowncast(value, to: AXUIElement.self))] + } +} + +extension AXElement { + /// Render an attribute value as text, or `nil` when there is no value. + /// + /// A batched read answers an absent attribute with CoreFoundation's null and an + /// unreadable one with a boxed error. Both are facts a dump wants to see and a + /// caller reading one attribute wants as nothing at all. + static func optionalText(_ value: CFTypeRef?) -> String? { + guard let value, CFGetTypeID(value) != CFNullGetTypeID() else { return nil } + + if CFGetTypeID(value) == AXValueGetTypeID(), + AXValueGetType(unsafeDowncast(value, to: AXValue.self)) == .axError + { + return nil + } + + return text(value) + } + + /// Render an attribute value as text. + /// + /// Values arrive as CoreFoundation types, including geometry boxed in + /// `AXValue` and references to other elements. Everything becomes a string so + /// that a reader can see which attributes exist and which carry identifiers + /// without this growing a case per boxed type. + static func text(_ value: CFTypeRef) -> String { + // An attribute the element advertises but cannot answer for, such as + // `AXSubrole` on an element that has none. + if CFGetTypeID(value) == CFNullGetTypeID() { + return "<null>" + } + if let text = value as? String { + return text + } + // Labels arrive as attributed strings more often than plain ones, and the + // attributes carry nothing the driver acts on. + if let attributed = value as? NSAttributedString { + return attributed.string + } + if let number = value as? NSNumber { + return number.stringValue + } + if let elements = value as? [AXUIElement] { + return "<\(elements.count) AXUIElement>" + } + if let array = value as? [Any] { + return "<array of \(array.count)>" + } + + let typeID = CFGetTypeID(value) + if typeID == AXUIElementGetTypeID() { + return "<AXUIElement>" + } + if typeID == AXValueGetTypeID() { + // The conditional form is rejected here: every CoreFoundation type is + // bridged as a class, so the compiler sees a cast that cannot fail. + // The type ID check above is the real test. + return text(unsafeDowncast(value, to: AXValue.self)) + } + return "<CFTypeID \(typeID)>" + } + + /// Render the geometry boxed in an `AXValue`. + /// + /// `AXActivationPoint` and `AXFrame` decide where a synthesized click lands, + /// so these arrive as numbers a reader can check against the screen rather + /// than as an opaque marker. + static func text(_ value: AXValue) -> String { + let type = AXValueGetType(value) + + switch type { + // A batched read reports a per-attribute failure by boxing the error + // rather than by failing the whole call. + case .axError: + var status = AXError.success + guard AXValueGetValue(value, .axError, &status) else { break } + return "<\(status.name)>" + + case .cgPoint: + var point = CGPoint.zero + guard AXValueGetValue(value, .cgPoint, &point) else { break } + return "\(point.x),\(point.y)" + + case .cgSize: + var size = CGSize.zero + guard AXValueGetValue(value, .cgSize, &size) else { break } + return "\(size.width)x\(size.height)" + + case .cgRect: + var rect = CGRect.zero + guard AXValueGetValue(value, .cgRect, &rect) else { break } + return "\(rect.origin.x),\(rect.origin.y) \(rect.size.width)x\(rect.size.height)" + + case .cfRange: + var range = CFRange() + guard AXValueGetValue(value, .cfRange, &range) else { break } + return "\(range.location)+\(range.length)" + + default: + break + } + + return "<AXValue \(type.rawValue)>" + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/AXErrorName.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/AXErrorName.swift new file mode 100644 index 000000000..68add5ffe --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/AXErrorName.swift @@ -0,0 +1,24 @@ +import ApplicationServices + +extension AXError { + /// A stable snake_case name for this error. + /// + /// Only the codes a read or an action can realistically produce are named. + /// Anything else keeps its numeric code rather than being flattened into + /// "unknown", so an unexpected failure stays traceable to a header. + var name: String { + switch self { + case .success: return "success" + case .apiDisabled: return "api_disabled" + case .cannotComplete: return "cannot_complete" + case .invalidUIElement: return "invalid_ui_element" + case .notImplemented: return "not_implemented" + case .attributeUnsupported: return "attribute_unsupported" + case .actionUnsupported: return "action_unsupported" + case .noValue: return "no_value" + case .illegalArgument: return "illegal_argument" + case .failure: return "failure" + default: return "ax_error_\(rawValue)" + } + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Act.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Act.swift new file mode 100644 index 000000000..2faf78dc7 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Act.swift @@ -0,0 +1,1218 @@ +import ApplicationServices +import Foundation + +/// One thing to do to one element. +/// +/// Each case names its own mechanism. There is no step that picks a mechanism +/// based on what the element supports: a script that says `select` against +/// something unselectable fails and says so, which is how a change in the app's +/// accessibility becomes visible instead of being absorbed by a fallback. +/// +/// Decoded from a single-key object, so a step reads as what it does: +/// +/// ```json +/// {"select": {"identifier": "sidebar.row.17855681129"}} +/// ``` +enum Step: Decodable { + /// Write `AXSelected` on the nearest ancestor that accepts it. + /// + /// The mechanism for list and outline rows, where the identified element is + /// below the one that owns selection. Independent of scroll position, so it + /// reaches a row that is not on screen. + case select(Target) + + /// Perform `AXPress` on the identified element itself. + /// + /// The mechanism for buttons and menu items, which advertise the action. + case press(Target) + + /// Synthesize a mouse click at the element's activation point. + /// + /// The last resort, and the only step that depends on the world outside the + /// accessibility tree: the window has to be frontmost and the element on + /// screen, or the click lands somewhere else entirely. + case click(Target) + + /// Perform a named accessibility action on the identified element. + /// + /// The long tail. `press` is this with `AXPress` and a better error message, + /// and is worth keeping because it is the overwhelmingly common case; anything + /// else an element advertises — `AXConfirm`, `AXShowMenu`, `AXScrollToVisible`, + /// `AXCancel` — is reached through here rather than by growing a step per verb. + case perform(ActionTarget) + + /// Put text into a text field. + case type(TypeTarget) + + /// Set an element's size, which for a window resizes it. + /// + /// The one step that changes the shape of what is on screen rather than what + /// is in it, and the only way to observe what a resize costs: a drag of a + /// window's edge cannot be synthesized against a background application, and + /// resizing is where a view that re-measures its contents shows up. + case resize(SizeTarget) + + /// Drag the pointer across an element, with the button held. + /// + /// The gesture no other step can stand in for. `resize` sets a window's size + /// in one write, which is not a drag: nothing enters live resize, and a view + /// that behaves differently *during* a gesture than after it looks correct to + /// every other step here. + /// + /// Not only for window edges. Any two points on any element — a split + /// divider's handle, a stretch of text to select, a row to drag out — is the + /// same gesture with different endpoints. + /// + /// Depends on the world outside the tree in the same way `click` does: the + /// events go to whatever occupies those coordinates, so the window is raised + /// first and has to be on screen. + case drag(DragTarget) + + /// What to drag across, and along what path. + struct DragTarget: Decodable { + /// The element's `AXIdentifier`, matched exactly. + /// + /// Names the coordinate space, not necessarily the thing that reacts. A + /// window's own frame is how its resize corner is addressed, and the + /// window is what reacts. + let identifier: String + + /// Where the button goes down, as a fraction of the element's frame. + let from: Offset + + /// Where it comes up. + let to: Offset + + /// How many moves to post between the two, not counting the press. + /// + /// Defaults to 24. The number is the point of the step: a drag posted as + /// one jump exercises a single frame, and the behaviour usually under + /// question is what happens across many. + let steps: Int? + + /// How long to pause between moves, in milliseconds. Defaults to 8. + let pauseMs: Int? + + private enum CodingKeys: String, CodingKey { + case identifier + case from + case to + case steps + case pauseMs = "pause_ms" + } + } + + /// A point on an element, as a fraction of its frame. + /// + /// Fractions rather than points, so a script says "the right edge, halfway + /// down" and keeps meaning it after the window is resized. + /// + /// `1.0` is the far edge exactly, and is what a window resize wants. The + /// region that resizes a window is a few points wide and straddles the frame + /// boundary, so aiming even five points inside lands in the content instead: + /// the gesture runs, the pointer moves, and whatever is under it gets dragged + /// rather than the window resized. Measured on a running window — `0.995` of + /// a 1070-point window grabs text, `1.0` grabs the edge. + struct Offset: Decodable { + let dx: Double + let dy: Double + } + + /// What to resize, and to what. + struct SizeTarget: Decodable { + /// The element's `AXIdentifier`, matched exactly. + /// + /// A window carries one, so it is addressed the same way as anything else + /// rather than through a step that means "the frontmost window". + let identifier: String + + /// The width to ask for, in points. + let width: Double + + /// The height to ask for, in points. + let height: Double + } + + /// What action to perform, and on what. + struct ActionTarget: Decodable { + /// The element's `AXIdentifier`, matched exactly. + let identifier: String + + /// The action's own name, spelled as the accessibility API spells it. + /// + /// Not translated from a friendlier vocabulary: a step that says + /// `AXConfirm` can be checked against what `jpdrive dump` reported for the + /// element, and a friendlier name could not. + let action: String + } + + /// Press a menu item, addressed by the titles leading to it. + case menu(MenuTarget) + + /// What to type, and where. + struct TypeTarget: Decodable { + /// The field's `AXIdentifier`, matched exactly. + let identifier: String + + /// The text to put in the field, replacing what is there. + /// + /// Written as a value and then confirmed, rather than typed a character at + /// a time. Two calls, neither of which can be derailed by focus moving to + /// another application halfway through, which a synthesized keystroke can. + /// + /// The confirm is not optional dressing. Writing `AXValue` on a `SwiftUI` + /// text field changes the text the field displays without the binding + /// behind it noticing, so the application carries on as though nothing was + /// typed. Confirming commits the edit through the path the binding does + /// observe. + /// + /// The cost is that per-character behaviour never runs. A field that + /// validates each keystroke, or completes as you type, sees one change + /// rather than a dozen. Assert the consequence — the list that narrowed, + /// the button that enabled — rather than assuming the field's own handlers + /// fired for every character. + let text: String + } + + /// Wait until an element with the given identifier exists. + case waitFor(WaitTarget) + + /// A path through a menu. + struct MenuTarget: Decodable { + /// Titles from the top of the menu downwards, such as `["File", "Close"]`. + /// + /// Titles rather than identifiers, because the structure is the thing worth + /// asserting. An identifier like `closeAll:` is an `AppKit` selector name: + /// it survives the item moving to a different menu, so a script keyed on it + /// cannot notice the menu bar being rearranged. A path cannot miss that. + /// + /// A path that does not resolve reports how far it got and what that level + /// holds, which is the assertion failure a layout test wants to read. + let path: [String] + + /// The element whose shown menu the path starts from. + /// + /// Absent, the path starts at the menu bar. Present, it starts at the menu + /// that element is currently displaying, which `AXShowMenu` puts up. + /// + /// A title is the only way to name a context menu item: `SwiftUI` does not + /// carry an accessibility identifier onto the `NSMenuItem` it bridges a + /// menu button to, so every item in one reports the same selector name. + let under: String? + + /// Spelled out so `under` can be left off, both here and on the wire. + init(path: [String], under: String? = nil) { + self.path = path + self.under = under + } + } + + /// What a wait addresses, and for how long. + struct WaitTarget: Decodable { + /// The `AXIdentifier` to wait for, matched exactly. + let identifier: String + + /// Identifier of a container to search inside, resolved once before + /// polling begins. + /// + /// Strongly worth setting. A search for something absent has no early exit + /// and reads every element in the application, which against a thousand-row + /// sidebar takes longer than a typical timeout allows for a single attempt. + /// Scoping to the container the element will appear in makes each poll + /// cheap. + /// + /// A container that does not exist fails immediately, rather than being + /// waited for. + let under: String? + + /// How long to keep trying. Defaults to 5000. + let timeoutMs: Int? + + /// How long to pause between attempts. Defaults to 100. + /// + /// Not the kind of sleep the driver avoids. Waiting a fixed duration and + /// assuming the work finished is a guess; pausing between two observations + /// of a condition is how polling stays off a busy loop that would flood the + /// target with accessibility traffic. + let intervalMs: Int? + + /// Spelled out, because the decoder converts no cases of its own: without + /// these two, a step naming `timeout_ms` decodes as though it had named + /// nothing and silently waits the default. + private enum CodingKeys: String, CodingKey { + case identifier + case under + case timeoutMs = "timeout_ms" + case intervalMs = "interval_ms" + } + } + + /// What a step addresses. + struct Target: Decodable { + /// The element's `AXIdentifier`, matched exactly. + /// + /// Exact rather than by prefix: `sidebar.row.1785` is a prefix of many + /// rows, and acting on whichever one happened to be found first is not a + /// thing a script can mean. + let identifier: String + } + + private enum CodingKeys: String, CodingKey { + case select + case press + case click + case perform + case type + case menu + case waitFor = "wait_for" + case resize + case drag + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let target = try container.decodeIfPresent(Target.self, forKey: .select) { + self = .select(target) + return + } + if let target = try container.decodeIfPresent(Target.self, forKey: .press) { + self = .press(target) + return + } + if let target = try container.decodeIfPresent(Target.self, forKey: .click) { + self = .click(target) + return + } + if let target = try container.decodeIfPresent(ActionTarget.self, forKey: .perform) { + self = .perform(target) + return + } + if let target = try container.decodeIfPresent(TypeTarget.self, forKey: .type) { + self = .type(target) + return + } + if let target = try container.decodeIfPresent(MenuTarget.self, forKey: .menu) { + self = .menu(target) + return + } + if let target = try container.decodeIfPresent(WaitTarget.self, forKey: .waitFor) { + self = .waitFor(target) + return + } + if let target = try container.decodeIfPresent(SizeTarget.self, forKey: .resize) { + self = .resize(target) + return + } + if let target = try container.decodeIfPresent(DragTarget.self, forKey: .drag) { + self = .drag(target) + return + } + + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: + "expected one of select, press, click, perform, type, menu, wait_for, " + + "resize, drag" + ) + ) + } +} + +/// What a step did. +struct StepResult: Encodable, Equatable { + /// The step that ran, named as it was written. + let step: String + + let identifier: String + + /// The role of the element the step acted on. + /// + /// Not always the identified element: `select` climbs to the ancestor that + /// owns selection, and reporting the role it reached is how a restructuring of + /// the view surfaces as a changed role rather than as a puzzling failure. + let role: String + + /// Whether the intended change was observed after the step ran. + /// + /// A write can succeed and change nothing, so this is read back from the + /// element rather than inferred from the API's status. + /// + /// Absent for a step with nothing to read back. Pressing a button runs + /// arbitrary code in the target and has no attribute that says it worked, so + /// reporting `true` there would be claiming more than was checked. + let confirmed: Bool? + + /// Where a click was aimed, in screen coordinates. + /// + /// Only `click` reports this. A click is the one step whose outcome depends on + /// a number the caller cannot otherwise see, and "it clicked the wrong thing" + /// is unanswerable without knowing where it clicked. + let point: String? + + /// Whether an edit was committed through the element's confirm action. + /// + /// Only `type` reports this. `false` means the field took the text but + /// advertises no `AXConfirm`, so whether the application noticed depends on it + /// watching the value directly — worth knowing, because the text being in the + /// field and the application having seen it are different facts. + let committed: Bool? + + /// The size the element ended at, as `WIDTHxHEIGHT` in points. + /// + /// Only `resize` reports this. A window clamps a size to its own limits, so + /// what was asked for and what happened are different facts and the second is + /// the one worth reading. + let size: String? + + /// How many moves a drag posted between pressing and releasing. + /// + /// Only `drag` reports this. It is what separates a gesture from a jump, and + /// a caller asking why a view did not react during one wants to know how many + /// chances it had. + let moves: Int? + + init( + step: String, + identifier: String, + role: String, + confirmed: Bool? = nil, + committed: Bool? = nil, + point: String? = nil, + size: String? = nil, + moves: Int? = nil + ) { + self.step = step + self.identifier = identifier + self.role = role + self.confirmed = confirmed + self.committed = committed + self.point = point + self.size = size + self.moves = moves + } +} + +/// Runs a single step against a running application. +enum Act { + /// How far `select` looks above the identified element for one that accepts + /// selection. + /// + /// The known chain is two levels, from the identified element through the cell + /// to the row. The cap is above that so an extra wrapper does not break the + /// step, and low enough that a miss fails rather than selecting the window. + private static let maxAncestors = 4 + + /// Resolve the step's target and act on it. + static func run(_ step: Step, pid: pid_t) throws(DriveError) -> StepResult { + guard ProcessTable.record(for: pid) != nil else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + + guard AXIsProcessTrusted() else { + throw DriveError( + kind: .notPermitted, + message: "not trusted to read another application's accessibility tree", + hint: DriveError.accessibilityHint + ) + } + + return try run(step, in: AXElement.application(pid: pid), poster: SystemEventPoster()) + } + + /// Run a step against an already-resolved root. + /// + /// Split from ``run(_:pid:)`` so the part with the logic in it can be exercised + /// against a tree that is not a running application. `poster` is separate for + /// the same reason: a click is aimed using the tree but delivered outside it. + /// `activation` is how long a menu step waits for the application to come + /// forward and for the item to enable, and is a parameter so a test of either + /// wait does not have to sit through the real one. + static func run<E: Element>( + _ step: Step, + in root: E, + poster: any EventPoster = SystemEventPoster(), + activation: Duration = activationTimeout + ) throws(DriveError) -> StepResult { + switch step { + case .select(let target): + return try select(target, in: root) + + case .waitFor(let target): + return try waitFor(target, in: root) + + case .press(let target): + return try press(target, in: root) + + case .perform(let target): + return try perform(target.action, on: target.identifier, in: root, step: "perform") + + case .type(let target): + return try type(target, in: root) + + case .menu(let target): + return try menu(target, in: root, within: activation) + + case .click(let target): + return try click(target, in: root, poster: poster) + + case .resize(let target): + return try resize(target, in: root) + + case .drag(let target): + return try drag(target, in: root, poster: poster, activation: activation) + } + } + + /// How many moves a drag posts when it does not say. + private static let defaultDragSteps = 24 + + /// How long a drag pauses between moves when it does not say. + private static let defaultDragPause = Duration.milliseconds(8) + + /// Drag the pointer from one point on an element to another. + private static func drag<E: Element>( + _ target: Step.DragTarget, + in root: E, + poster: any EventPoster, + activation: Duration + ) throws(DriveError) -> StepResult { + let path = try find(target.identifier, from: root) + guard let element = path.last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(target.identifier)", + hint: nil + ) + } + + guard + let origin = element.point(kAXPositionAttribute), + let size = element.size(kAXSizeAttribute) + else { + throw DriveError( + kind: .notClickable, + message: "\(target.identifier) reports no frame to drag across", + hint: "an element with no position or size cannot be aimed at" + ) + } + + let steps = max(target.steps ?? defaultDragSteps, 1) + let pause = target.pauseMs.map { Duration.milliseconds($0) } ?? defaultDragPause + + let start = point(target.from, in: origin, size) + let end = point(target.to, in: origin, size) + let route = (0...steps).map { step in + let progress = Double(step) / Double(steps) + return CGPoint( + x: start.x + (end.x - start.x) * progress, + y: start.y + (end.y - start.y) * progress + ) + } + + // Activated, and then raised, and both are needed. + // + // `AXRaise` orders a window forward *within its own application*. Global + // ordering between applications follows activation, so raising a + // background app's window leaves it under the active app's windows: the + // gesture lands on whatever is on top at those coordinates, which is + // whatever the person at the keyboard is using. Measured, not assumed — a + // drag posted without this was received by the frontmost terminal. + // + // The cost is that a gesture takes focus. Nothing here can give it back: + // this process handles one step and exits, so the restore belongs to + // whatever drives the whole list. + activate(root, within: activation) + raiseWindow(in: path) + + guard poster.drag(through: route, pausing: pause) else { + throw DriveError( + kind: .actionFailed, + message: + "could not post a drag from \(start.x),\(start.y) to \(end.x),\(end.y)", + hint: nil + ) + } + + return StepResult( + step: "drag", + identifier: target.identifier, + role: element.read([kAXRoleAttribute]).text[0] ?? "<none>", + point: "\(start.x),\(start.y) -> \(end.x),\(end.y)", + moves: route.count - 1 + ) + } + + /// One fractional offset as a screen coordinate inside a frame. + private static func point( + _ offset: Step.Offset, in origin: CGPoint, _ size: CGSize + ) + -> CGPoint + { + CGPoint( + x: origin.x + size.width * offset.dx, + y: origin.y + size.height * offset.dy + ) + } + + /// Ask the identified element to take a new size. + /// + /// The size it ends at is read back and reported rather than assumed: a window + /// clamps to its own minimum and maximum, so asking for something outside those + /// succeeds and lands somewhere else. `confirmed` says whether it landed on + /// what was asked for. + private static func resize<E: Element>( + _ target: Step.SizeTarget, in root: E + ) throws(DriveError) -> StepResult { + let path = try find(target.identifier, from: root) + guard let element = path.last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(target.identifier)", + hint: "list what is addressable with: jpdrive tree --identifier <prefix>" + ) + } + + let role = element.read([kAXRoleAttribute]).text[0] ?? "" + + guard element.isSettable(kAXSizeAttribute) else { + throw DriveError( + kind: .notEditable, + message: "\(target.identifier) does not accept a write to AXSize", + hint: "a window does; most elements inside one do not" + ) + } + + let wanted = CGSize(width: target.width, height: target.height) + let status = element.setSize(kAXSizeAttribute, wanted) + guard status == .success else { + throw DriveError( + kind: .writeFailed, + message: "writing AXSize on \(target.identifier) failed: \(status.name)", + hint: nil + ) + } + + let reached = element.size(kAXSizeAttribute) + + return StepResult( + step: "resize", + identifier: target.identifier, + role: role, + confirmed: reached == wanted, + size: reached.map { "\(Int($0.width))x\(Int($0.height))" } + ) + } + + /// Click where the identified element says a click belongs. + /// + /// The last resort among the steps, and the only one whose effect is not + /// addressed to the element: the event goes to whatever occupies that screen + /// coordinate. Prefer `select` for rows and `press` for controls, both of which + /// reach their target regardless of what is on top of it or whether it is + /// scrolled into view. + private static func click<E: Element>( + _ target: Step.Target, + in root: E, + poster: any EventPoster + ) throws(DriveError) -> StepResult { + let path = try find(target.identifier, from: root) + guard let element = path.last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(target.identifier)", + hint: nil + ) + } + + guard let point = element.point(AXElement.activationPoint) else { + throw DriveError( + kind: .notClickable, + message: "\(target.identifier) reports no AXActivationPoint", + hint: + "an element with no place to be clicked is usually one that wants `select` " + + "or `press` instead" + ) + } + + // Raised first, because the click lands on whatever is at that coordinate + // rather than on the element that named it. A window behind another one + // would otherwise have its click swallowed by the window in front. + raiseWindow(in: path) + + guard poster.click(at: point) else { + throw DriveError( + kind: .actionFailed, + message: "could not post a click at \(point.x),\(point.y)", + hint: nil + ) + } + + return StepResult( + step: "click", + identifier: target.identifier, + role: element.read([kAXRoleAttribute]).text[0] ?? "<none>", + point: "\(point.x),\(point.y)" + ) + } + + /// Bring the application forward, ignoring a refusal. + /// + /// Best effort, unlike ``front(_:within:)``, which fails a menu step that + /// cannot activate: there the activation *is* the step, because AppKit + /// disables every item acting on the front window until the application is + /// frontmost. A pointer gesture only needs to be on top of the z-order, and a + /// tree that is not a running application — a test's — has nothing to + /// activate and a gesture against it is still worth posting. + private static func activate<E: Element>(_ root: E, within timeout: Duration) { + guard root.flag(kAXFrontmostAttribute) != true else { return } + guard root.setFlag(kAXFrontmostAttribute, true) == .success else { return } + + _ = poll(untilTrue: { root.flag(kAXFrontmostAttribute) == true }, within: timeout) + } + + /// Bring the window holding the addressed element to the front, if it has one. + /// + /// Found along the path the search descended, for the same reason the selection + /// owner is: the identified element does not report a parent to climb from. + private static func raiseWindow<E: Element>(in path: [E]) { + for element in path where element.read([kAXRoleAttribute]).text[0] == kAXWindowRole { + _ = element.perform(kAXRaiseAction) + return + } + } + + /// Press the identified element. + private static func press<E: Element>( + _ target: Step.Target, in root: E + ) throws(DriveError) + -> StepResult + { + return try perform(kAXPressAction, on: target.identifier, in: root, step: "press") + } + + /// Perform `action` on the element with `identifier`. + /// + /// `step` names the result, so `press` reports itself rather than the general + /// mechanism it is a shorthand for. + private static func perform<E: Element>( + _ action: String, + on identifier: String, + in root: E, + step: String + ) throws(DriveError) -> StepResult { + let path = try find(identifier, from: root) + guard let element = path.last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(identifier)", + hint: nil + ) + } + + // Checked before performing, so the error can name what the element does + // accept. Performing an unsupported action answers `action_unsupported` + // with nothing to act on. + let actions = element.actions + guard actions.contains(action) else { + throw DriveError( + kind: .actionUnsupported, + message: "\(identifier) does not accept \(action)", + hint: actions.isEmpty + ? "it advertises no actions at all; a list row is activated with `select`" + : "it accepts: \(actions.joined(separator: ", "))" + ) + } + + let status = element.perform(action) + guard status == .success else { + throw DriveError( + kind: .actionFailed, + message: "performing \(action) on \(identifier) answered \(status.name)", + hint: nil + ) + } + + return StepResult( + step: step, + identifier: identifier, + role: element.read([kAXRoleAttribute]).text[0] ?? "<none>", + confirmed: nil + ) + } + + /// Put text into the identified field. + private static func type<E: Element>( + _ target: Step.TypeTarget, in root: E + ) throws(DriveError) + -> StepResult + { + let path = try find(target.identifier, from: root) + guard let element = path.last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(target.identifier)", + hint: nil + ) + } + + guard element.isSettable(kAXValueAttribute) else { + throw DriveError( + kind: .notEditable, + message: "\(target.identifier) does not accept a write to AXValue", + hint: "a static label and a disabled field both look like this; check with " + + "`jpdrive dump --settable`" + ) + } + + let status = element.setText(kAXValueAttribute, target.text) + guard status == .success else { + throw DriveError( + kind: .writeFailed, + message: "writing AXValue to \(target.identifier) answered \(status.name)", + hint: nil + ) + } + + let committed = try confirm(element, identifier: target.identifier) + + // `confirmed` says the field holds the text, and nothing more. Whether the + // application reacted is the caller's assertion to make, against whatever + // the typing was supposed to change. + let after = element.read([kAXValueAttribute, kAXRoleAttribute]) + + return StepResult( + step: "type", + identifier: target.identifier, + role: after.text[1] ?? "<none>", + confirmed: after.text[0] == target.text, + committed: committed + ) + } + + /// Commit an edit, if the element offers a way to. + /// + /// Answers whether it did. An element with no confirm action is not a failure: + /// some fields publish every change as it happens and need nothing further. + private static func confirm<E: Element>( + _ element: E, identifier: String + ) throws(DriveError) -> Bool { + guard element.actions.contains(kAXConfirmAction) else { return false } + + let status = element.perform(kAXConfirmAction) + guard status == .success else { + throw DriveError( + kind: .actionFailed, + message: "confirming \(identifier) answered \(status.name)", + hint: + "the text was written but not committed, so the application has not seen it" + ) + } + + return true + } + + /// Attributes read while walking a menu path. + private static let menuBatch = [ + kAXRoleAttribute, + AXElement.attributedDescription, + kAXDescriptionAttribute, + kAXTitleAttribute, + ] + + /// How long to wait for the application to come forward, and for the item + /// addressed through it to be enabled. + static let activationTimeout = Duration.milliseconds(2000) + + /// Press the menu item at the end of a titled path. + private static func menu<E: Element>( + _ target: Step.MenuTarget, in root: E, within timeout: Duration + ) throws(DriveError) + -> StepResult + { + guard !target.path.isEmpty else { + throw DriveError( + kind: .badUsage, + message: "a menu step needs a path, such as [\"File\", \"Close\"]", + hint: nil + ) + } + + let start: E + let origin: String + + if let owner = target.under { + // A menu already on screen. No activation: showing it required the + // application to be active, and asking again would be a no-op at best. + start = try shownMenu(of: owner, in: root) + origin = "'\(owner)' is showing a menu that" + } else { + // The one step that takes focus from whatever had it. AppKit disables + // every menu item that acts on the front window or on the responder + // chain while the application is in the background, which is most of + // the menu bar: without this, a path resolves to an item that cannot + // be pressed. + try front(root, within: timeout) + + guard let bar = root.elements(kAXMenuBarAttribute).first else { + throw DriveError( + kind: .notFound, + message: "the application reports no menu bar", + hint: "an agent or accessory application has none" + ) + } + start = bar + origin = "the menu bar" + } + + var current = start + var reached: [String] = [] + + for title in target.path { + guard let next = child(titled: title, of: current) else { + throw DriveError( + kind: .notFound, + message: reached.isEmpty + ? "\(origin) holds no item titled '\(title)'" + : "'\(reached.joined(separator: " > "))' holds no item titled '\(title)'", + hint: "it holds: \(titles(of: current).joined(separator: ", "))" + ) + } + current = next + reached.append(title) + } + + let path = target.path.joined(separator: " > ") + try waitUntilEnabled(current, named: path, within: timeout) + + let actions = current.actions + guard actions.contains(kAXPressAction) else { + throw DriveError( + kind: .actionUnsupported, + message: "'\(path)' does not accept AXPress", + hint: actions.isEmpty + ? "the path names a submenu rather than an item; name the item inside it" + : "it accepts: \(actions.joined(separator: ", "))" + ) + } + + let status = current.perform(kAXPressAction) + guard status == .success else { + throw DriveError( + kind: .actionFailed, + message: "pressing '\(path)' answered \(status.name)", + hint: nil + ) + } + + return StepResult( + step: "menu", + identifier: path, + role: current.read([kAXRoleAttribute]).text[0] ?? "<none>", + confirmed: nil + ) + } + + /// The menu an element is currently displaying. + /// + /// A shown menu hangs off the element that opened it, after that element's + /// own children, which is why a capped or filtered read passes straight over + /// it. + private static func shownMenu<E: Element>( + of identifier: String, in root: E + ) throws(DriveError) -> E { + let path = try find(identifier, from: root) + guard let owner = path.last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(identifier)", + hint: nil + ) + } + + let children = owner.read([kAXRoleAttribute]).children + for child in children where child.read([kAXRoleAttribute]).text[0] == kAXMenuRole { + return child + } + + throw DriveError( + kind: .notFound, + message: "\(identifier) is not showing a menu", + hint: """ + open one first, in an earlier step: \ + {"perform": {"identifier": "\(identifier)", "action": "AXShowMenu"}} + """ + ) + } + + /// Bring the application forward, and wait until it reports that it is. + /// + /// Writing `AXFrontmost` is a request. The window server grants it a moment + /// later, and the menu validation that depends on it later still. + private static func front<E: Element>( + _ root: E, within timeout: Duration + ) throws(DriveError) { + guard root.flag(kAXFrontmostAttribute) != true else { return } + + let status = root.setFlag(kAXFrontmostAttribute, true) + guard status == .success else { + throw DriveError( + kind: .writeFailed, + message: "bringing the application forward answered \(status.name)", + hint: "a menu item that acts on the front window is disabled until it is" + ) + } + + guard poll(untilTrue: { root.flag(kAXFrontmostAttribute) == true }, within: timeout) + else { + throw DriveError( + kind: .timeout, + message: + "the application did not come forward within \(timeout.milliseconds)ms", + hint: "another application may be holding focus with a modal panel" + ) + } + } + + /// Wait for an item to stop reporting itself disabled. + /// + /// An element that reports no `AXEnabled` at all is not disabled: plenty + /// carry no such attribute, and treating its absence as a refusal would + /// reject every one of them. + private static func waitUntilEnabled<E: Element>( + _ item: E, named path: String, within timeout: Duration + ) throws(DriveError) { + if poll(untilTrue: { item.flag(kAXEnabledAttribute) != false }, within: timeout) { + return + } + + throw DriveError( + kind: .disabled, + message: "'\(path)' is disabled", + hint: + "an item acting on a selection is disabled while nothing is selected, and one " + + "acting on the front window while no window has focus" + ) + } + + /// Poll `condition` until it holds, or `timeout` elapses. + private static func poll(untilTrue condition: () -> Bool, within timeout: Duration) -> Bool + { + let clock = ContinuousClock() + let started = clock.now + + while true { + if condition() { return true } + guard clock.now - started < timeout else { return false } + Thread.sleep(forTimeInterval: defaultInterval.seconds) + } + } + + /// The child of `parent` whose title is `title`. + /// + /// Descends through `AXMenu`, which carries no title of its own: a bar item's + /// items live inside one, so a path names `["File", "Close"]` rather than + /// spelling out the container between them. + private static func child<E: Element>(titled title: String, of parent: E) -> E? { + for child in parent.read([]).children { + let text = child.read(menuBatch).text + + if text[1] ?? text[2] ?? text[3] == title { + return child + } + + guard text[0] == "AXMenu", let found = self.child(titled: title, of: child) else { + continue + } + return found + } + + return nil + } + + /// The titles a level offers, for saying what a path could have named instead. + private static func titles<E: Element>(of parent: E) -> [String] { + var found: [String] = [] + + for child in parent.read([]).children { + let text = child.read(menuBatch).text + + if let title = text[1] ?? text[2] ?? text[3], !title.isEmpty { + found.append(title) + continue + } + + // An untitled `AXMenu` is the container a path skips, so what it holds + // is what this level effectively offers. + guard text[0] == "AXMenu" else { continue } + found.append(contentsOf: titles(of: child)) + } + + return found + } + + /// Select the row that owns the identified element. + private static func select<E: Element>( + _ target: Step.Target, in root: E + ) throws(DriveError) + -> StepResult + { + let path = try find(target.identifier, from: root) + + guard let owner = selectionOwner(in: path) else { + throw DriveError( + kind: .notSelectable, + message: + "neither \(target.identifier) nor its \(maxAncestors) nearest ancestors accept " + + "a write to AXSelected", + hint: "check what the element reports with: jpdrive dump --settable" + ) + } + + let status = owner.setFlag(kAXSelectedAttribute, true) + guard status == .success else { + throw DriveError( + kind: .writeFailed, + message: "writing AXSelected to \(target.identifier) answered \(status.name)", + hint: nil + ) + } + + return StepResult( + step: "select", + identifier: target.identifier, + role: owner.read([kAXRoleAttribute]).text[0] ?? "<none>", + confirmed: owner.flag(kAXSelectedAttribute) ?? false + ) + } + + /// Default time to keep polling for an element to appear. + private static let defaultTimeout = Duration.milliseconds(5000) + + /// Default pause between polling attempts. + private static let defaultInterval = Duration.milliseconds(100) + + /// Wait until an element with the target's identifier exists. + /// + /// Returns as soon as it is found, including on the first attempt when it was + /// already there. + private static func waitFor<E: Element>( + _ target: Step.WaitTarget, in root: E + ) throws(DriveError) + -> StepResult + { + // Resolved once, before the loop. This is the expensive search, and paying + // it on every attempt is what makes an unscoped wait useless. + let scope: E + if let under = target.under { + guard let container = try find(under, from: root).last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(under) to wait inside", + hint: "`under` names a container that must already exist" + ) + } + scope = container + } else { + scope = root + } + + let timeout = target.timeoutMs.map { Duration.milliseconds($0) } ?? defaultTimeout + let interval = target.intervalMs.map { Duration.milliseconds($0) } ?? defaultInterval + + let clock = ContinuousClock() + let started = clock.now + var attempts = 0 + + while true { + attempts += 1 + + if let path = try? find(target.identifier, from: scope), let found = path.last { + return StepResult( + step: "wait_for", + identifier: target.identifier, + role: found.read([kAXRoleAttribute]).text[0] ?? "<none>", + confirmed: true + ) + } + + guard clock.now - started < timeout else { break } + Thread.sleep(forTimeInterval: interval.seconds) + } + + let elapsed = clock.now - started + throw DriveError( + kind: .timeout, + message: + "\(target.identifier) did not appear within \(timeout.milliseconds)ms " + + "(\(attempts) attempts over \(elapsed.milliseconds)ms)", + hint: attempts == 1 + ? "one attempt exhausted the timeout; scope the search with `under`" + : nil + ) + } + + /// The nearest element at or above the end of `path` that accepts a write to + /// `AXSelected`. + /// + /// Walks the chain the search descended rather than reading `AXParent`. The + /// identified element is a SwiftUI leaf that does not report a parent, so + /// climbing from it arrives nowhere, while the chain that reached it is known + /// for free and is not subject to that. + private static func selectionOwner<E: Element>(in path: [E]) -> E? { + for element in path.suffix(maxAncestors + 1).reversed() + where element.isSettable(kAXSelectedAttribute) { + return element + } + return nil + } + + /// What a search reads at each element. + /// + /// Children arrive alongside, so the identifier is all the search asks for. + private static let searchBatch = [kAXIdentifierAttribute] + + /// Find the element whose identifier is exactly `identifier`, and the chain of + /// elements that reached it. + /// + /// The path comes back rather than the element alone because acting on an + /// element often means acting on one of its ancestors, and this tree cannot + /// reliably be walked upwards. + /// + /// Depth-first with an early exit, reading only what the search needs. Reading + /// every attribute of each element on the way past would make a step against + /// this app's few thousand elements cost seconds. + private static func find<E: Element>( + _ identifier: String, from root: E + ) throws(DriveError) + -> [E] + { + var stack = [[root]] + + while let path = stack.popLast() { + guard let element = path.last else { continue } + let reading = element.read(searchBatch) + + if reading.text[0] == identifier { + return path + } + + // Reversed, so a depth-first walk visits siblings in the order the + // application reports them. + for child in reading.children.reversed() { + stack.append(path + [child]) + } + } + + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(identifier)", + hint: "list what is addressable with: jpdrive tree --identifier <prefix>" + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Ambient.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Ambient.swift new file mode 100644 index 000000000..0ad381f26 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Ambient.swift @@ -0,0 +1,80 @@ +import AppKit +import Foundation + +/// The state a driven run borrows from whoever is at the keyboard. +/// +/// Which application is in front, and where the pointer is. Neither belongs to +/// the app under test: a synthesized gesture has to take both — mouse events go +/// to whatever is on top at a coordinate, and the ordering between applications +/// follows activation — and a run that takes them owes them back. +/// +/// Deliberately not window geometry. A step that resizes a window did the thing +/// it was asked to do, and putting the window back would undo the effect under +/// test. What a run borrows is restored; what it was told to change is not. +/// +/// Read and written separately rather than as one capture-and-restore pair, so a +/// caller composes what it needs and decides for itself when a restore is owed. +enum Ambient { + /// The bundle identifier of the frontmost application. + /// + /// `nil` when there is none, or when it has no identifier — a process + /// launched without a bundle has neither. + static func frontmost() -> FrontmostReport { + FrontmostReport(bundleID: NSWorkspace.shared.frontmostApplication?.bundleIdentifier) + } + + /// Bring the application with `bundleID` back to the front. + /// + /// Through `NSWorkspace`, which asks the application to activate itself, so + /// this needs no permission beyond launching one. Answers whether an + /// application with that identifier was found to ask. + static func activate(bundleID: String) -> FrontmostReport { + guard + let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID) + .first + else { + return FrontmostReport(bundleID: nil) + } + + app.activate() + return FrontmostReport(bundleID: bundleID) + } + + /// Where the pointer is, in the coordinates a synthesized event uses. + /// + /// `NSEvent.mouseLocation` is bottom-left origin and screen coordinates are + /// top-left, so the y is flipped here rather than at each call site. The + /// height flipped against is the *main* screen's, which is what the window + /// server measures global coordinates from. + static func pointer() -> PointerReport { + let location = NSEvent.mouseLocation + let height = NSScreen.screens.first?.frame.height ?? 0 + + return PointerReport(x: location.x, y: height - location.y) + } + + /// Put the pointer back at `point`. + /// + /// Warped rather than moved: `CGWarpMouseCursorPosition` relocates the cursor + /// without synthesizing motion, so nothing under it takes a hover, and no + /// application sees a gesture it has to interpret. + static func movePointer(to point: CGPoint) -> PointerReport { + CGWarpMouseCursorPosition(point) + return PointerReport(x: point.x, y: point.y) + } +} + +/// Which application is in front. +struct FrontmostReport: Encodable, Equatable { + let bundleID: String? + + private enum CodingKeys: String, CodingKey { + case bundleID = "bundle_id" + } +} + +/// Where the pointer is, in top-left-origin screen coordinates. +struct PointerReport: Encodable, Equatable { + let x: CGFloat + let y: CGFloat +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Arguments.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Arguments.swift new file mode 100644 index 000000000..64fdbcfb0 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Arguments.swift @@ -0,0 +1,425 @@ +import Foundation + +/// A parsed command line. +enum Command { + /// Report whether this process may read another app's accessibility tree. + case doctor(pid: pid_t?) + + /// Print the elements and attributes under an application. + case dump(DumpOptions) + + /// Report the elements under an application, identified and described. + case tree(TreeOptions) + + /// List the application's windows. + case windows(pid: pid_t) + + /// Report the window-server identifiers of the application's windows. + case windowid(pid: pid_t) + + /// Report the application's menu bar. + case menu(pid: pid_t, options: TreeOptions) + + /// Do one thing to one element. + case act(step: Step, pid: pid_t) + + /// Report the colours along one row or column of a screenshot. + case pixels(PixelOptions) + + /// Report which application is in front, or put one there. + case frontmost(set: String?) + + /// Report where the pointer is, or put it somewhere. + case pointer(set: CGPoint?) +} + +/// What to walk, and how much of it. +struct DumpOptions { + let pid: pid_t + + /// How deep to recurse before reporting a node's children as elided. + let maxDepth: Int + + /// How many children to walk at each level, or `0` for all of them. + let maxSiblings: Int + + /// Whether to ask, per attribute, if it can be written. + let settable: Bool +} + +/// The command line the driver accepts. +/// +/// Hand-rolled rather than pulled from `swift-argument-parser`: the package has +/// no other dependency, and keeping it that way means the build needs no network +/// and no resolved manifest. +enum Arguments { + /// Usage text, embedded in every bad-usage error. + static let usage = """ + usage: jpdrive doctor [--pid <pid>] + jpdrive tree --pid <pid> [--identifier <prefix>] [--max-matches <n>] + [--frames] [--depth <n>] [--max-siblings <n>] + jpdrive windows --pid <pid> + jpdrive windowid --pid <pid> + jpdrive menu --pid <pid> [--depth <n>] [--max-siblings <n>] + jpdrive dump --pid <pid> [--depth <n>] [--max-siblings <n>] [--settable] + jpdrive act --pid <pid> --json '<step>' + a step is a single-key object, e.g. + {"resize":{"identifier":"w","width":1400,"height":900}} + jpdrive frontmost [--set <bundle-id>] + jpdrive pointer [--set <x>,<y>] + jpdrive pixels --image <path> --scan row|column --at <n> + [--from <n>] [--to <n>] + """ + + /// Depth cap for `dump` when `--depth` is not given. + /// + /// A SwiftUI window nests deeply: the wrapper groups between a `List` and its + /// rows are several levels on their own, so a cap low enough to be tidy hides + /// the elements worth seeing. + static let defaultDepth = 20 + + /// Sibling cap for `dump` when `--max-siblings` is not given. + /// + /// A thousand sidebar rows are a thousand copies of one shape, and walking + /// them all costs a round-trip per attribute per element. Five is enough to + /// see the shape and to tell a homogeneous list from a mixed one. + static let defaultSiblings = 5 + + /// Match budget for a filtered `tree` when `--max-matches` is not given. + /// + /// Identifiers sit on leaves, so a prefix search cannot prune on the way down + /// and an unbounded one reads every element in the application. Five answers + /// what a list looks like; looking up one known identifier wants `1`. + static let defaultMatches = 5 + + /// Parse `arguments`, which excludes the executable path. + static func parse(_ arguments: [String]) throws(DriveError) -> Command { + guard let subcommand = arguments.first else { + throw DriveError(kind: .badUsage, message: usage, hint: nil) + } + + let options = try options(arguments.dropFirst()) + + switch subcommand { + case "doctor": + return .doctor(pid: options.pid) + + case "dump": + guard let pid = options.pid else { + throw DriveError( + kind: .badUsage, + message: "dump needs --pid <pid>", + hint: usage + ) + } + return .dump( + DumpOptions( + pid: pid, + maxDepth: options.depth ?? defaultDepth, + maxSiblings: options.siblings ?? defaultSiblings, + settable: options.settable + ) + ) + + case "tree": + guard let pid = options.pid else { + throw DriveError( + kind: .badUsage, message: "tree needs --pid <pid>", hint: usage) + } + return .tree( + TreeOptions( + pid: pid, + identifierPrefix: options.identifier, + maxMatches: options.matches ?? defaultMatches, + maxDepth: options.depth ?? defaultDepth, + maxSiblings: options.siblings ?? defaultSiblings, + frames: options.frames + ) + ) + + case "frontmost": + return .frontmost(set: options.set) + + case "pointer": + guard let raw = options.set else { + return .pointer(set: nil) + } + + let parts = raw.split(separator: ",") + guard + parts.count == 2, + let x = Double(parts[0].trimmingCharacters(in: .whitespaces)), + let y = Double(parts[1].trimmingCharacters(in: .whitespaces)) + else { + throw DriveError( + kind: .badUsage, + message: "pointer --set takes <x>,<y>", + hint: usage + ) + } + return .pointer(set: CGPoint(x: x, y: y)) + + case "windows": + guard let pid = options.pid else { + throw DriveError( + kind: .badUsage, message: "windows needs --pid <pid>", hint: usage) + } + return .windows(pid: pid) + + case "windowid": + guard let pid = options.pid else { + throw DriveError( + kind: .badUsage, message: "windowid needs --pid <pid>", hint: usage) + } + return .windowid(pid: pid) + + case "menu": + guard let pid = options.pid else { + throw DriveError( + kind: .badUsage, message: "menu needs --pid <pid>", hint: usage) + } + return .menu( + pid: pid, + options: TreeOptions( + pid: pid, + identifierPrefix: options.identifier, + maxMatches: options.matches ?? defaultMatches, + maxDepth: options.depth ?? defaultDepth, + // A menu bar is a couple of hundred elements and every one of + // them is a thing you might press, so the default that keeps a + // thousand-row list readable would only hide half the verbs. + maxSiblings: options.siblings ?? 0, + frames: options.frames + ) + ) + + case "act": + guard let pid = options.pid else { + throw DriveError(kind: .badUsage, message: "act needs --pid <pid>", hint: usage) + } + guard let json = options.json else { + throw DriveError( + kind: .badUsage, message: "act needs --json '<step>'", hint: usage) + } + + let step: Step + do { + step = try JSONDecoder().decode(Step.self, from: Data(json.utf8)) + } catch { + throw DriveError( + kind: .badUsage, + message: "could not read the step: \(error)", + hint: #"a step is a single-key object, e.g. {"select":{"identifier":"…"}}"# + ) + } + + return .act(step: step, pid: pid) + + case "pixels": + guard let image = options.image else { + throw DriveError( + kind: .badUsage, message: "pixels needs --image <path>", hint: usage) + } + guard let scan = options.scan else { + throw DriveError( + kind: .badUsage, + message: "pixels needs --scan row or --scan column", + hint: usage + ) + } + guard let at = options.at else { + throw DriveError( + kind: .badUsage, message: "pixels needs --at <n>", hint: usage) + } + + return .pixels( + PixelOptions( + image: image, + axis: scan, + at: at, + from: options.from, + to: options.to + ) + ) + + default: + throw DriveError( + kind: .badUsage, + message: "unknown subcommand '\(subcommand)'", + hint: usage + ) + } + } + + /// Flags accepted by any subcommand, whether or not that subcommand reads + /// them. Keeping one parser means `--pid` behaves identically everywhere. + private struct Options { + var pid: pid_t? + var depth: Int? + var siblings: Int? + var matches: Int? + var settable = false + var identifier: String? + var json: String? + var frames = false + var image: String? + var scan: PixelOptions.Axis? + var at: Int? + var from: Int? + var to: Int? + var set: String? + } + + private static func options(_ arguments: ArraySlice<String>) throws(DriveError) -> Options { + var options = Options() + var rest = arguments.makeIterator() + + while let argument = rest.next() { + switch argument { + case "--pid": + guard let value = rest.next(), + let raw = Int(value), + let pid = pid_t(exactly: raw) + else { + throw DriveError( + kind: .badUsage, + message: "--pid takes an integer process id", + hint: """ + \(usage). `--pid $(pgrep -f JP.app)` expands to nothing \ + when the app is not running, which lands here rather \ + than reporting app_not_running + """ + ) + } + options.pid = pid + + case "--depth": + guard let value = rest.next(), let depth = Int(value), depth > 0 else { + throw DriveError( + kind: .badUsage, + message: "--depth takes a positive integer", + hint: usage + ) + } + options.depth = depth + + case "--max-siblings": + guard let value = rest.next(), let siblings = Int(value), siblings >= 0 else { + throw DriveError( + kind: .badUsage, + message: + "--max-siblings takes a non-negative integer, where 0 means all", + hint: usage + ) + } + options.siblings = siblings + + case "--settable": + options.settable = true + + case "--max-matches": + guard let value = rest.next(), let matches = Int(value), matches > 0 else { + throw DriveError( + kind: .badUsage, + message: "--max-matches takes a positive integer", + hint: usage + ) + } + options.matches = matches + + case "--frames": + options.frames = true + + case "--identifier": + guard let value = rest.next() else { + throw DriveError( + kind: .badUsage, + message: "--identifier takes a value", + hint: usage + ) + } + options.identifier = value + + case "--set": + guard let value = rest.next() else { + throw DriveError( + kind: .badUsage, + message: "--set takes a value", + hint: usage + ) + } + options.set = value + + case "--json": + guard let value = rest.next() else { + throw DriveError( + kind: .badUsage, + message: "--json takes a value", + hint: usage + ) + } + options.json = value + + case "--image": + guard let value = rest.next() else { + throw DriveError( + kind: .badUsage, + message: "--image takes a path", + hint: usage + ) + } + options.image = value + + case "--scan": + guard let value = rest.next(), let axis = PixelOptions.Axis(rawValue: value) + else { + throw DriveError( + kind: .badUsage, + message: "--scan takes `row` or `column`", + hint: usage + ) + } + options.scan = axis + + case "--at": + guard let value = rest.next(), let at = Int(value), at >= 0 else { + throw DriveError( + kind: .badUsage, + message: "--at takes a non-negative integer", + hint: usage + ) + } + options.at = at + + case "--from": + guard let value = rest.next(), let from = Int(value), from >= 0 else { + throw DriveError( + kind: .badUsage, + message: "--from takes a non-negative integer", + hint: usage + ) + } + options.from = from + + case "--to": + guard let value = rest.next(), let to = Int(value), to >= 0 else { + throw DriveError( + kind: .badUsage, + message: "--to takes a non-negative integer", + hint: usage + ) + } + options.to = to + + default: + throw DriveError( + kind: .badUsage, + message: "unknown argument '\(argument)'", + hint: usage + ) + } + } + + return options + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Doctor.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Doctor.swift new file mode 100644 index 000000000..b6a474a2b --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Doctor.swift @@ -0,0 +1,95 @@ +import ApplicationServices +import Foundation + +/// What `jpdrive doctor` observed. +struct DoctorReport: Encodable { + /// `AXIsProcessTrusted()` for this process. + let trusted: Bool + + /// This process and its ancestors, nearest first. One of these holds the + /// Accessibility grant when `trusted` is true. + let processes: [ProcessLink] + + /// A real read against a target app, present when `--pid` was given. + let probe: WindowProbe? +} + +/// The outcome of reading a target application's window list. +struct WindowProbe: Encodable { + let pid: pid_t + + /// The target's short command name. + let command: String + + /// How many windows were read, when the read succeeded. + let windowCount: Int? + + /// The accessibility error, when it did not. + let axError: String? +} + +/// Answers whether this process may read another application's accessibility +/// tree, and records the evidence for why. +/// +/// `AXIsProcessTrusted()` alone is not enough: it reports what TCC believes +/// about the responsible process, which is not always the process making the +/// call. So the report pairs the flag with a real `AXUIElementCopyAttributeValue` +/// against a live app, and with the ancestor chain the grant might be attributed +/// to. Apple documents neither the attribution algorithm nor its stability, so +/// this records observations rather than asserting a rule. +enum Doctor { + /// Run every probe and collect the results. + /// + /// Throws only when `pid` names a process that is not running. A refused + /// accessibility read is an observation the report carries, not a failure of + /// the diagnostic. + static func run(targetPid pid: pid_t?) throws(DriveError) -> DoctorReport { + let probe: WindowProbe? + if let pid { + guard let record = ProcessTable.record(for: pid) else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + probe = windowProbe(pid: pid, command: ProcessTable.name(of: record)) + } else { + probe = nil + } + + return DoctorReport( + trusted: AXIsProcessTrusted(), + processes: ProcessTable.ancestry(from: getpid()), + probe: probe + ) + } + + /// Read the target's window list, reporting the accessibility error instead + /// of the count when the read is refused. + /// + /// Uses the non-prompting trust path throughout: a spike that raises the + /// system's "grant access" dialog changes the state it is measuring. + private static func windowProbe(pid: pid_t, command: String) -> WindowProbe { + let app = AXUIElementCreateApplication(pid) + var value: CFTypeRef? + let status = AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &value) + + guard status == .success else { + return WindowProbe( + pid: pid, + command: command, + windowCount: nil, + axError: status.name + ) + } + + let windows = value as? [AXUIElement] + return WindowProbe( + pid: pid, + command: command, + windowCount: windows?.count ?? 0, + axError: nil + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/DriveError.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/DriveError.swift new file mode 100644 index 000000000..c08b6ebaf --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/DriveError.swift @@ -0,0 +1,81 @@ +import Foundation + +/// A failure reported as JSON on stdout, alongside a non-zero exit status. +/// +/// Every exit path produces either a result document or one of these, so a +/// caller never has to scrape prose off stderr to find out what happened. +struct DriveError: Error, Encodable { + /// Machine-readable discriminator. Callers switch on this; the message is + /// for humans and may be reworded freely. + enum Kind: String, Encodable { + /// The command line named an unknown subcommand or was missing a value. + case badUsage = "bad_usage" + + /// No process is running under the given pid. + case appNotRunning = "app_not_running" + + /// The accessibility API refused the request for want of a TCC grant. + case notPermitted = "not_permitted" + + /// No element carries the identifier the step addressed. + case identifierNotFound = "identifier_not_found" + + /// The addressed element and its nearest ancestors do not accept a write + /// to `AXSelected`. + case notSelectable = "not_selectable" + + /// An attribute write was refused by the accessibility API. + case writeFailed = "write_failed" + + /// The addressed element does not accept a write to its value. + case notEditable = "not_editable" + + /// The addressed element reports nowhere on screen to click. + case notClickable = "not_clickable" + + /// The addressed element does not accept the action the step performs. + case actionUnsupported = "action_unsupported" + + /// The addressed element is present but refuses to act while disabled. + case disabled = "disabled" + + /// An action was refused by the accessibility API. + case actionFailed = "action_failed" + + /// An element waited for did not appear in time. + case timeout = "timeout" + + /// The application reports no element of the requested kind. + case notFound = "not_found" + + /// The result could not be encoded as JSON. + case encodingFailed = "encoding_failed" + } + + let kind: Kind + + /// One sentence saying what went wrong. + let message: String + + /// What the operator can do about it, when there is something to do. + var hint: String? +} + +extension DriveError { + /// Names the System Settings pane that grants Accessibility. + /// + /// macOS attributes the grant to the responsible process, which for a + /// command-line tool is normally the terminal rather than the tool, so this + /// points at the terminal and not at `jpdrive`. + static let accessibilityHint = """ + grant Accessibility to the terminal application running this command, \ + under System Settings > Privacy & Security > Accessibility, then start \ + a new terminal session + """ +} + +/// Envelope that makes an error document distinguishable from a result document +/// by its top-level key alone. +struct ErrorDocument: Encodable { + let error: DriveError +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Driver.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Driver.swift new file mode 100644 index 000000000..d3e222961 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Driver.swift @@ -0,0 +1,68 @@ +import Foundation + +/// The driver's entry point. +/// +/// Everything below this is internal to the library, so the tests reach it with +/// `@testable import` and the executable target stays a single line. +public enum Driver { + /// Parse the process arguments, run the command, and exit. + /// + /// Writes one JSON document to stdout either way: a result, or an error with a + /// non-zero exit status. + public static func run() -> Never { + do throws(DriveError) { + try dispatch(Array(CommandLine.arguments.dropFirst())) + } catch { + Output.writeError(error) + exit(1) + } + + exit(0) + } + + /// Run one command and write its result. + static func dispatch(_ arguments: [String]) throws(DriveError) { + switch try Arguments.parse(arguments) { + case .doctor(let pid): + try Output.write(try Doctor.run(targetPid: pid)) + + case .dump(let options): + try Output.write(try Dump.walk(options)) + + case .tree(let options): + guard let tree = try Tree.read(options) else { + throw DriveError( + kind: .identifierNotFound, + message: + "no element's identifier begins with \(options.identifierPrefix ?? "")", + hint: "drop --identifier to see what the application reports" + ) + } + try Output.write(tree) + + case .windows(let pid): + try Output.write(try Windows.read(pid: pid)) + + case .windowid(let pid): + try Output.write(try WindowIDs.read(pid: pid)) + + case .menu(let pid, let options): + try Output.write(try Menu.read(pid: pid, options: options)) + + case .act(let step, let pid): + try Output.write(try Act.run(step, pid: pid)) + + case .pixels(let options): + try Output.write(try Pixels.read(options)) + + case .frontmost(let set): + let report = + if let set { Ambient.activate(bundleID: set) } else { Ambient.frontmost() } + try Output.write(report) + + case .pointer(let set): + let report = if let set { Ambient.movePointer(to: set) } else { Ambient.pointer() } + try Output.write(report) + } + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Dump.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Dump.swift new file mode 100644 index 000000000..9b9ae62dd --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Dump.swift @@ -0,0 +1,119 @@ +import ApplicationServices +import Foundation + +/// One accessibility attribute, as reported name and rendered value. +/// +/// A list of pairs rather than a dictionary, so attribute names reach the JSON +/// exactly as the accessibility API spells them. `JSONEncoder`'s snake-case key +/// strategy rewrites dictionary keys, which would turn `AXIdentifier` into +/// `ax_identifier` and make the dump a poor record of what the app reports. +struct DumpAttribute: Encodable { + let name: String + let value: String + + /// Whether the accessibility API reports this attribute as writable, when + /// settability was asked for. + /// + /// This decides how the driver changes state. Writing `AXSelected` on a row is + /// deterministic; synthesizing a click at a screen coordinate depends on the + /// window being frontmost and unobscured. + /// + /// Absent unless requested: answering it costs one round-trip per attribute, + /// which doubles the cost of a walk. + let settable: Bool? +} + +/// One element of an application's accessibility tree, with everything it reports. +struct DumpNode: Encodable { + /// `AXRole`, lifted out of the attributes because it is what a reader scans + /// for. + let role: String + + /// Every attribute the element reports, minus the two that only lead back into + /// the tree, sorted by name. + let attributes: [DumpAttribute] + + /// Actions the element accepts, such as `AXPress`. + let actions: [String] + + let children: [DumpNode] + + /// How many children were dropped to keep the walk bounded. + /// + /// Absent when every child was walked. A sidebar of a thousand conversations + /// repeats one row shape a thousand times, so the count is the useful part and + /// the repetition is not. + let elidedChildren: Int? +} + +/// Walks an application's accessibility tree and reports everything it finds. +/// +/// This is a design instrument. SwiftUI's mapping onto accessibility elements is +/// undocumented and not one-to-one, so decisions about how to address and act on +/// an element are made by reading a real dump rather than by predicting where a +/// `.accessibilityIdentifier` lands. +/// +/// Unfiltered by intent: every attribute of every element it visits, so nothing +/// that turns out to matter has been quietly dropped. [`Tree`](Tree) is the +/// filtered counterpart for everyday use. +enum Dump { + /// Walk the tree rooted at the application owning `options.pid`. + static func walk(_ options: DumpOptions) throws(DriveError) -> DumpNode { + guard ProcessTable.record(for: options.pid) != nil else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(options.pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + + // Checked up front rather than reported per element: without the grant + // every read fails, and a tree of identical refusals says less than one + // error naming the pane that fixes it. + guard AXIsProcessTrusted() else { + throw DriveError( + kind: .notPermitted, + message: "not trusted to read another application's accessibility tree", + hint: DriveError.accessibilityHint + ) + } + + return node(AXElement.application(pid: options.pid), depth: 0, options: options) + } + + /// Attributes that only lead back into the tree, and so are not recorded. + /// + /// `AXChildren` is what the walk recurses into, and `AXParent` points at the + /// element that just reported this one. + private static let structuralAttributes: Set<String> = [ + kAXChildrenAttribute, + kAXParentAttribute, + ] + + private static func node(_ element: AXElement, depth: Int, options: DumpOptions) -> DumpNode + { + let names = + element.names() + .filter { !structuralAttributes.contains($0) } + .sorted() + + let attributes = zip(names, element.values(names)).map { name, value in + DumpAttribute( + name: name, + value: value.map(AXElement.text) ?? "<null>", + settable: options.settable ? element.isSettable(name) : nil + ) + } + + let all = depth < options.maxDepth ? element.children : [] + let walked = options.maxSiblings > 0 ? Array(all.prefix(options.maxSiblings)) : all + + return DumpNode( + role: attributes.first { $0.name == kAXRoleAttribute }?.value ?? "<none>", + attributes: attributes, + actions: element.actions, + children: walked.map { node($0, depth: depth + 1, options: options) }, + elidedChildren: all.count > walked.count ? all.count - walked.count : nil + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Duration.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Duration.swift new file mode 100644 index 000000000..09ef2dbce --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Duration.swift @@ -0,0 +1,15 @@ +import Foundation + +extension Duration { + /// The duration in whole milliseconds, for reporting. + var milliseconds: Int { + let (seconds, attoseconds) = components + return Int(seconds) * 1000 + Int(attoseconds / 1_000_000_000_000_000) + } + + /// The duration in seconds, for the APIs that take a `TimeInterval`. + var seconds: TimeInterval { + let (seconds, attoseconds) = components + return TimeInterval(seconds) + TimeInterval(attoseconds) / 1e18 + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Element.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Element.swift new file mode 100644 index 000000000..dd2ef2a30 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Element.swift @@ -0,0 +1,186 @@ +import ApplicationServices + +extension Optional where Wrapped == String { + /// The value read as a boolean. + /// + /// The accessibility API renders `AXEnabled`, `AXMain` and their like as `"0"` + /// or `"1"`. Anything else, including an absent attribute, is neither true nor + /// false. + var axFlag: Bool? { + switch self { + case "0": return false + case "1": return true + default: return nil + } + } +} + +/// Posts synthesized input to the window server. +/// +/// Behind a protocol because posting is the one thing the driver does that is not +/// addressed to an element. A click goes to whatever occupies a screen +/// coordinate, which is global state and cannot be exercised against a fake tree +/// the way every other step can. +protocol EventPoster { + /// Click once at `point`, in screen coordinates. + /// + /// Answers whether the events could be built and posted, which is not whether + /// anything received them. + func click(at point: CGPoint) -> Bool + + /// Press at the first point of `path`, move through the rest, release at the + /// last. + /// + /// `pause` separates one move from the next. Without it the moves are posted + /// faster than the target can consume them and the window server delivers a + /// coalesced few, which is the opposite of what a drag is usually being + /// synthesized to exercise: what a view does *during* the gesture, frame by + /// frame. + /// + /// Answers whether every event could be built and posted. + func drag(through path: [CGPoint], pausing pause: Duration) -> Bool +} + +/// Posts through `CoreGraphics`. +struct SystemEventPoster: EventPoster { + func click(at point: CGPoint) -> Bool { + guard + let down = event(.leftMouseDown, at: point), + let up = event(.leftMouseUp, at: point) + else { + return false + } + + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) + return true + } + + func drag(through path: [CGPoint], pausing pause: Duration) -> Bool { + guard let first = path.first, let last = path.last else { return false } + guard let down = event(.leftMouseDown, at: first) else { return false } + + down.post(tap: .cghidEventTap) + + for point in path.dropFirst() { + guard let moved = event(.leftMouseDragged, at: point) else { + // Released wherever it got to rather than returned from. A drag + // abandoned with the button still down leaves the whole machine + // holding a mouse button nobody is pressing, which outlives this + // process and is not something a failed test should do to the + // person running it. + release(at: point) + return false + } + + moved.post(tap: .cghidEventTap) + Thread.sleep(forTimeInterval: pause.seconds) + } + + guard let up = event(.leftMouseUp, at: last) else { + release(at: last) + return false + } + + up.post(tap: .cghidEventTap) + return true + } + + /// Let the button go, on a path that could not be finished. + private func release(at point: CGPoint) { + event(.leftMouseUp, at: point)?.post(tap: .cghidEventTap) + } + + private func event(_ type: CGEventType, at point: CGPoint) -> CGEvent? { + CGEvent( + mouseEventSource: nil, + mouseType: type, + mouseCursorPosition: point, + mouseButton: .left + ) + } +} + +/// Attribute text and children, read together. +/// +/// The pair exists because reading them separately costs an extra round-trip per +/// element, and walking to a child is the most common read the driver makes. +struct Reading<E> { + /// One entry per requested name, positionally, `nil` where the element has no + /// value for that attribute. + let text: [String?] + + let children: [E] +} + +/// One element of an accessibility tree, as the driver's traversal needs it. +/// +/// The traversal is where the driver's logic lives: pruning a filtered walk, +/// spending a match budget, finding which ancestor of an identified element owns +/// selection. None of that is about the accessibility API, and all of it has been +/// wrong at least once. Behind this protocol it can be tested against a fake tree +/// instead of against a running application. +/// +/// Deliberately narrow. Everything here is something a walk actually does, so a +/// fake stays small enough to read at a glance and cannot drift far from the real +/// implementation. +protocol Element { + /// Read the named attributes and the element's children. + /// + /// Implementations batch: this is one round-trip in the real one. + func read(_ names: [String]) -> Reading<Self> + + /// Actions the element accepts, such as `AXPress`. + /// + /// Separate from ``read(_:)`` because it costs its own round-trip and most + /// elements a filtered walk passes through are discarded unread. + var actions: [String] { get } + + /// Whether `name` can be written on this element. + func isSettable(_ name: String) -> Bool + + /// Read a boolean attribute, `nil` when it is absent or not a boolean. + func flag(_ name: String) -> Bool? + + /// Write a boolean attribute, answering the accessibility API's own status. + /// + /// A successful write is not a successful change: the target can accept the + /// value and do nothing with it. Read it back to find out. + func setFlag(_ name: String, _ value: Bool) -> AXError + + /// Write a string attribute, answering the accessibility API's own status. + func setText(_ name: String, _ value: String) -> AXError + + /// Perform an action, answering the accessibility API's own status. + /// + /// What the action did is not observable from here. Pressing a button runs + /// arbitrary code in the target, and success means the press was delivered, + /// not that anything came of it. + func perform(_ action: String) -> AXError + + /// The point held in an attribute, in screen coordinates. + /// + /// `nil` when the attribute is absent or holds something else. Separate from + /// ``read(_:)`` because a caller aiming a click needs the numbers, not the + /// text they render as. + func point(_ name: String) -> CGPoint? + + /// The size held in an attribute, in points. + /// + /// `nil` when the attribute is absent or holds something else. + func size(_ name: String) -> CGSize? + + /// Write a size attribute, answering the accessibility API's own status. + /// + /// As with every other write here, success is not change: a window clamps a + /// size to its own minimum and maximum, so what it ends up at has to be read + /// back. + func setSize(_ name: String, _ value: CGSize) -> AXError + + /// The elements held in an attribute, such as `AXWindows` or `AXMenuBar`. + /// + /// Answers a single element as a one-element array, since the accessibility + /// API spells "the menu bar" and "the windows" the same way apart from the + /// plural. + func elements(_ name: String) -> [Self] +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Menu.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Menu.swift new file mode 100644 index 000000000..a1c595b9d --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Menu.swift @@ -0,0 +1,52 @@ +import ApplicationServices +import Foundation + +/// Reads an application's menu bar. +/// +/// Reported as a tree, because a menu is one: bar, then bar items, then menus, +/// then items. What makes it worth its own subcommand is the root — reaching the +/// menu bar from the application element takes an attribute that holds it, not a +/// walk through the window hierarchy. +/// +/// Menu items are pressed with `act press`; they advertise `AXPress` where a list +/// row does not. +enum Menu { + /// Read the menu bar of the application owning `pid`. + static func read(pid: pid_t, options: TreeOptions) throws(DriveError) -> TreeNode { + guard ProcessTable.record(for: pid) != nil else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + + guard AXIsProcessTrusted() else { + throw DriveError( + kind: .notPermitted, + message: "not trusted to read another application's accessibility tree", + hint: DriveError.accessibilityHint + ) + } + + let app = AXElement.application(pid: pid) + + guard let bar = app.elements(kAXMenuBarAttribute).first else { + throw DriveError( + kind: .notFound, + message: "the application reports no menu bar", + hint: "an agent or accessory application has none" + ) + } + + guard let tree = Tree.walk(from: bar, options: options) else { + throw DriveError( + kind: .notFound, + message: "the menu bar held nothing matching", + hint: nil + ) + } + + return tree + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Output.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Output.swift new file mode 100644 index 000000000..47ca031d5 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Output.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Writes the driver's JSON documents. +/// +/// Both results and errors go to stdout, so a caller reads one stream and +/// distinguishes the two by the top-level `error` key or by the exit status. +enum Output { + /// Encode `value` as pretty JSON on stdout, with a trailing newline. + static func write(_ value: some Encodable) throws(DriveError) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + // Snake case on the wire, matching every other JSON payload JP produces. + encoder.keyEncodingStrategy = .convertToSnakeCase + + let data: Data + do { + data = try encoder.encode(value) + } catch { + throw DriveError( + kind: .encodingFailed, + message: "could not encode the result as JSON: \(error)", + hint: nil + ) + } + + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data("\n".utf8)) + } + + /// Write an error document. + /// + /// Falls back to hand-built JSON, so a caller still gets something parseable + /// in the case where even the error will not encode. + static func writeError(_ error: DriveError) { + do throws(DriveError) { + try write(ErrorDocument(error: error)) + } catch { + let message = error.message.replacingOccurrences(of: "\"", with: "'") + let json = #"{"error":{"kind":"encoding_failed","message":"\#(message)"}}"# + "\n" + FileHandle.standardOutput.write(Data(json.utf8)) + } + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Pixels.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Pixels.swift new file mode 100644 index 000000000..d9a0c8d8d --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Pixels.swift @@ -0,0 +1,252 @@ +import CoreGraphics +import Foundation +import ImageIO + +/// One stretch of identical pixels along a scanline. +struct PixelRun: Encodable, Equatable { + /// Where the run begins, in pixels along the scan. + let start: Int + + /// How many pixels it covers. + let count: Int + + /// The colour, `#RRGGBB` when opaque and `#RRGGBBAA` when it is not. + let color: String +} + +/// What one scan across an image found. +struct PixelReport: Encodable, Equatable { + /// The image's width in pixels, which on a retina display is twice its width + /// in points. + let width: Int + + /// The image's height in pixels. + let height: Int + + /// The colour space the values are reported in. + /// + /// Always sRGB. Stated anyway, because the numbers mean nothing without it: + /// the same screenshot read in the display's own profile and in sRGB gives two + /// different sets of values for the same pixels, and a light grey moves by + /// several steps between them. + /// + /// sRGB because that is the space colours are *written* in — a palette + /// constant, a value from a colour picker, a hex in a design note — so a + /// reading can be compared against the thing it was supposed to be. + let colorSpace: String + + /// Which way the scan ran: `row` or `column`. + let scan: String + + /// The row or column that was read, in pixels. + let at: Int + + /// The runs along it, in order, covering the scanned range without gaps. + let runs: [PixelRun] +} + +/// What to scan, and where. +struct PixelOptions { + /// Which way a scan runs. + enum Axis: String { + /// Left to right, across one row. + case row + + /// Top to bottom, down one column. + case column + } + + /// The PNG to read. + let image: String + + let axis: Axis + + /// The row or column to read, in pixels. + let at: Int + + /// Where along the scan to start, in pixels. The near edge when absent. + let from: Int? + + /// Where along the scan to stop, inclusive, in pixels. The far edge when + /// absent. + let to: Int? +} + +/// Reads the pixels of a screenshot. +/// +/// Answers the questions the accessibility tree cannot: what colour something is, +/// and how wide a drawn thing is. A hairline, a selection fill, a divider and a +/// row separator are all invisible to the tree, and all obvious in a scanline. +/// +/// Reads a file rather than capturing one. Capture already has a home +/// (`screencapture`, driven by `debug_app_screenshot`), and the only ways to +/// capture from inside this process are deprecated. It also makes this testable +/// against an image built by hand, with no window server and no grants. +enum Pixels { + /// Scan `options.image` and report the runs along the requested line. + static func read(_ options: PixelOptions) throws(DriveError) -> PixelReport { + let bitmap = try Bitmap(path: options.image) + let extent = options.axis == .row ? bitmap.width : bitmap.height + let across = options.axis == .row ? bitmap.height : bitmap.width + + guard options.at >= 0, options.at < across else { + throw DriveError( + kind: .notFound, + message: + "\(options.axis.rawValue) \(options.at) is outside the image, which is " + + "\(bitmap.width)x\(bitmap.height) pixels", + hint: "a row is indexed down from the top and a column across from the left" + ) + } + + let from = max(options.from ?? 0, 0) + let to = min(options.to ?? extent - 1, extent - 1) + + guard from <= to else { + throw DriveError( + kind: .badUsage, + message: "--from \(from) is past --to \(to)", + hint: "both are pixel offsets along the scan, and --to is inclusive" + ) + } + + let line = (from...to).map { along in + options.axis == .row + ? bitmap.pixel(x: along, y: options.at) + : bitmap.pixel(x: options.at, y: along) + } + + return PixelReport( + width: bitmap.width, + height: bitmap.height, + colorSpace: bitmap.colorSpace, + scan: options.axis.rawValue, + at: options.at, + runs: runs(of: line, startingAt: from) + ) + } + + /// Collapse `line` into runs of one colour, the first starting at `start`. + /// + /// The whole point of the output shape: a scan across a window is thousands of + /// pixels and a handful of colours, and the edges between them are the + /// measurements a reader is after. + static func runs(of line: [Pixel], startingAt start: Int) -> [PixelRun] { + var runs: [PixelRun] = [] + + for (offset, pixel) in line.enumerated() { + if let last = runs.last, last.color == pixel.hex { + runs[runs.count - 1] = PixelRun( + start: last.start, count: last.count + 1, color: last.color) + continue + } + + runs.append(PixelRun(start: start + offset, count: 1, color: pixel.hex)) + } + + return runs + } +} + +/// One pixel, as read out of an image. +struct Pixel: Equatable { + let red: UInt8 + let green: UInt8 + let blue: UInt8 + let alpha: UInt8 + + /// `#RRGGBB` when opaque, `#RRGGBBAA` when not. + /// + /// Alpha is left off the common case so the values read the way a colour + /// picker reports them, and included when it is not 255 because a translucent + /// pixel that printed as opaque would be a lie about what is on screen. + var hex: String { + let rgb = String(format: "#%02X%02X%02X", red, green, blue) + return alpha == 255 ? rgb : rgb + String(format: "%02X", alpha) + } +} + +/// An image's pixels, in the image's own colour space. +private struct Bitmap { + let width: Int + let height: Int + let colorSpace: String + + /// RGBA, row-major, four bytes per pixel and no row padding. + private let bytes: [UInt8] + + /// Decode the PNG at `path`. + init(path: String) throws(DriveError) { + guard + let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { + throw DriveError( + kind: .notFound, + message: "could not read an image at \(path)", + hint: "debug_app_screenshot writes one, and reports where it put it" + ) + } + + width = image.width + height = image.height + + // Converted rather than read raw. `screencapture` writes in the display's + // profile, which is often unnamed and never the space a palette was + // written in: a `#DBDBDB` divider comes back as `#D6D6D6` read that way, + // which looks like a bug in the app rather than a difference of space. + let target = CGColorSpace(name: CGColorSpace.sRGB) + + guard + let target, + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: target, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + throw DriveError( + kind: .notFound, + message: "could not open \(path) as an 8-bit RGBA image", + hint: nil + ) + } + + colorSpace = "sRGB" + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + + guard let data = context.data else { + throw DriveError( + kind: .notFound, + message: "the drawing context for \(path) reported no pixels", + hint: nil + ) + } + + bytes = [UInt8]( + UnsafeBufferPointer( + start: data.assumingMemoryBound(to: UInt8.self), + count: width * height * 4 + )) + } + + /// The pixel at `x`, `y`, counted from the top-left corner. + /// + /// The buffer runs in the same direction: a bitmap context's first row is the + /// top of what was drawn into it, so a screenshot's rows and this buffer's rows + /// are the same rows in the same order. + func pixel(x: Int, y: Int) -> Pixel { + let offset = (y * width + x) * 4 + + return Pixel( + red: bytes[offset], + green: bytes[offset + 1], + blue: bytes[offset + 2], + alpha: bytes[offset + 3] + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/ProcessTable.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/ProcessTable.swift new file mode 100644 index 000000000..9aed316e4 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/ProcessTable.swift @@ -0,0 +1,58 @@ +import Darwin +import Foundation + +/// One process in the chain from the driver up towards `launchd`. +struct ProcessLink: Encodable { + let pid: pid_t + + /// Short command name from the kernel process table. The kernel truncates it + /// to 16 bytes, so `Terminal` and `iTerm2` arrive whole but a long binary + /// name does not. + let command: String +} + +/// Process identity read from the kernel through `sysctl(KERN_PROC_PID)`. +/// +/// The spike needs the ancestor chain because TCC attributes a grant to the +/// responsible process, and the chain is the list of candidates for that role. +enum ProcessTable { + /// Depth limit for the ancestor walk. A shell-to-`launchd` chain is a + /// handful of processes; the limit only guards against a process table that + /// changes underneath the walk. + private static let maxDepth = 32 + + /// `pid` and its ancestors, nearest first, stopping below `launchd`. + static func ancestry(from pid: pid_t) -> [ProcessLink] { + var links: [ProcessLink] = [] + var current = pid + + while current > 1, links.count < maxDepth { + guard let record = record(for: current) else { break } + links.append(ProcessLink(pid: current, command: name(of: record))) + current = record.kp_eproc.e_ppid + } + + return links + } + + /// The kernel's record for `pid`, or `nil` when no such process is running. + static func record(for pid: pid_t) -> kinfo_proc? { + var selector: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + var record = kinfo_proc() + var size = MemoryLayout<kinfo_proc>.stride + + let result = sysctl(&selector, u_int(selector.count), &record, &size, nil, 0) + + // Querying a pid that no longer exists succeeds and writes nothing, so + // the written size is what separates a dead pid from a live one. + guard result == 0, size > 0 else { return nil } + return record + } + + /// The short command name held in a kernel record. + static func name(of record: kinfo_proc) -> String { + return withUnsafeBytes(of: record.kp_proc.p_comm) { bytes in + return String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self) + } + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Tree.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Tree.swift new file mode 100644 index 000000000..c9a6f4ad5 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Tree.swift @@ -0,0 +1,183 @@ +import ApplicationServices +import Foundation + +/// One element, as the driver reports it. +/// +/// An absent field means the element does not report that attribute. An +/// `AXUnknown` row carries a label and no value; a text field carries both. +struct TreeNode: Encodable, Equatable { + let role: String + let identifier: String? + let label: String? + let value: String? + let enabled: Bool? + let focused: Bool? + + /// The element's frame in screen coordinates, only when frames were asked for. + /// + /// Left out by default: coordinates change whenever a window moves or a list + /// scrolls, so including them turns every diff between two snapshots into + /// noise. + let frame: String? + + let actions: [String] + let children: [TreeNode] + + /// How many of this element's children are missing from ``children``. + /// + /// Every reason a child goes missing is counted the same, because they answer + /// one question: is there more here than I am looking at? The depth limit, the + /// per-level sibling cap, the match budget running out, and a filter discarding + /// a branch that held no match all leave the reader in the same position, and + /// the last two are the easiest to mistake for an element having no children at + /// all. + let elidedChildren: Int? +} + +/// What to walk, and what to keep. +struct TreeOptions { + let pid: pid_t + + /// Keep only elements whose identifier begins with this, along with the + /// ancestors that lead to them. `nil` keeps everything. + /// + /// A prefix rather than an exact match, because the useful question to ask of a + /// tree is "what is under `sidebar.`". Acting on an element is the opposite + /// case and matches exactly. + let identifierPrefix: String? + + /// How many matches to find before stopping. + /// + /// This is the bound that matters. Every identifier in this app's sidebar sits + /// on a leaf, so a prefix search cannot prune on the way down and an unbounded + /// one visits every element in the application. Stopping at a handful of + /// matches answers "what does the sidebar look like" for the cost of the first + /// handful rather than of all thousand. + /// + /// Set this to `1` when looking up one identifier already known, or the walk + /// continues past it looking for a second. + let maxMatches: Int + + let maxDepth: Int + let maxSiblings: Int + let frames: Bool +} + +/// Reads an application's accessibility tree into something a person can scan. +/// +/// Where [`Dump`](Dump) reports every attribute of every element for design work, +/// this reports the handful that identify and describe an element, and prunes +/// branches holding nothing that matched. +enum Tree { + /// Attributes read for every node, in one batch. Order matters, since values + /// come back positionally. + static let batch = [ + kAXRoleAttribute, + kAXIdentifierAttribute, + AXElement.attributedDescription, + kAXDescriptionAttribute, + kAXTitleAttribute, + kAXValueAttribute, + kAXEnabledAttribute, + kAXFocusedAttribute, + "AXFrame", + ] + + /// Walk the tree of the application owning `options.pid`. + /// + /// Returns `nil` when a prefix was given and nothing matched it. + static func read(_ options: TreeOptions) throws(DriveError) -> TreeNode? { + guard ProcessTable.record(for: options.pid) != nil else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(options.pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + + guard AXIsProcessTrusted() else { + throw DriveError( + kind: .notPermitted, + message: "not trusted to read another application's accessibility tree", + hint: DriveError.accessibilityHint + ) + } + + return walk(from: AXElement.application(pid: options.pid), options: options) + } + + /// Walk from `root`, spending a fresh match budget. + static func walk<E: Element>(from root: E, options: TreeOptions) -> TreeNode? { + // An unfiltered walk has no matches to count, so the budget only bounds a + // filtered one. + var remaining = options.identifierPrefix == nil ? Int.max : options.maxMatches + return node(root, depth: 0, options: options, remaining: &remaining) + } + + private static func node<E: Element>( + _ element: E, + depth: Int, + options: TreeOptions, + remaining: inout Int + ) -> TreeNode? { + let reading = element.read(batch) + let text = reading.text + + let identifier = text[1] + let matches = + options.identifierPrefix.map { identifier?.hasPrefix($0) ?? false } ?? false + if matches { + remaining -= 1 + } + + // Every child the element has, whether or not this walk descends into it. + // The count is what tells a reader there is more here; without it a node + // stopped at the depth limit is indistinguishable from a leaf. + let available = reading.children + let all = depth < options.maxDepth ? available : [] + + // The sibling cap is for reading an unfiltered tree, where every level is + // worth seeing but a thousand copies of one row are not. Under a filter the + // match budget does the bounding instead: a cap here would hide the eight + // hundredth row from a search that named it. + let capped = options.identifierPrefix == nil && options.maxSiblings > 0 + + var children: [TreeNode] = [] + var visited = 0 + + for child in all { + guard remaining > 0 else { break } + guard !capped || visited < options.maxSiblings else { break } + visited += 1 + + guard + let node = node( + child, depth: depth + 1, options: options, remaining: &remaining) + else { continue } + children.append(node) + } + + // A branch is kept when it matches, or when something under it does. The + // ancestors are what make a match locatable rather than a bare hit. + guard matches || !children.isEmpty || options.identifierPrefix == nil else { + return nil + } + + return TreeNode( + role: text[0] ?? "<none>", + identifier: identifier, + label: text[2] ?? text[3] ?? text[4], + value: text[5], + enabled: text[6].axFlag, + focused: text[7].axFlag, + frame: options.frames ? text[8] : nil, + // Read only for a node being kept. Actions cost their own round-trip and + // most elements a filtered walk passes through are discarded. + actions: element.actions, + children: children, + elidedChildren: available.count > children.count + ? available.count - children.count + : nil + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/WindowIDs.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/WindowIDs.swift new file mode 100644 index 000000000..063735580 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/WindowIDs.swift @@ -0,0 +1,133 @@ +import CoreGraphics +import Foundation + +/// A window the window server can be told to capture. +struct CaptureWindow: Encodable, Equatable { + /// The window server's identifier, in the form `screencapture -l` takes. + let id: CGWindowID + + /// The window's title, or `nil` when this process holds no Screen Recording + /// grant: the window server withholds other applications' titles until it + /// does. + let title: String? + + let width: Int + let height: Int +} + +/// What `jpdrive windowid` observed. +struct WindowIDReport: Encodable, Equatable { + /// Whether this process may read other applications' screen content. + /// + /// Enumerating windows needs no grant, so a report can list windows that + /// cannot be captured. A caller that acts on the list without reading this + /// gets a picture of the desktop where it expected a window. + let screenRecording: Bool + + /// The application's capturable windows, front to back. + let windows: [CaptureWindow] + + /// Windows the application has that are not on the active Space. + /// + /// Reported separately because the two look identical from the outside and + /// mean opposite things. A window on another desktop is absent from every + /// on-screen enumeration and from the accessibility tree, so an app that has + /// one and nothing else is indistinguishable from an app with no window at + /// all — except by asking for windows on every Space, which is this list. + let otherSpaces: [CaptureWindow] +} + +/// Resolves an application's window-server identifiers. +/// +/// Separate from `Windows`, which reads the accessibility tree: the two answer +/// different questions and neither identifier converts into the other. An +/// accessibility window has a title and a frame but no number the capture tools +/// accept, and a window-server window has that number but nothing structural. +enum WindowIDs { + /// The layer ordinary application windows sit on. + /// + /// Everything else the window server reports for an application is chrome — + /// tooltips, drag images, the shadow behind a menu — and capturing one of + /// those instead of the window is a silent wrong answer rather than a + /// failure. + static let normalLayer = 0 + + /// The capturable windows of the application owning `pid`. + static func read(pid: pid_t) throws(DriveError) -> WindowIDReport { + guard ProcessTable.record(for: pid) != nil else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + + let onScreen = + CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] ?? [] + + // Every Space, not just the active one. The difference between the two + // lists is what says a window exists somewhere the screen cannot show it. + let everywhere = + CGWindowListCopyWindowInfo( + [.excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] ?? [] + + let here = capturable(from: onScreen, pid: pid) + let all = capturable(from: everywhere, pid: pid) + let shown = Set(here.map(\.id)) + + // The preflight variant, never the requesting one: raising the system's + // permission dialog from a background tool leaves a prompt nobody is + // watching, in front of the app being measured. + return WindowIDReport( + screenRecording: CGPreflightScreenCaptureAccess(), + windows: here, + otherSpaces: all.filter { !shown.contains($0.id) } + ) + } + + /// The windows in `listed` that belong to `pid` and can be captured, + /// in the order the window server reported them, which is front to back. + /// + /// A window with no area is dropped: `AppKit` keeps zero-sized windows + /// around for panels that have never been shown, and capturing one produces + /// an empty file. + static func capturable(from listed: [[String: Any]], pid: pid_t) -> [CaptureWindow] { + return listed.compactMap { window -> CaptureWindow? in + guard integer(window[kCGWindowOwnerPID as String]) == Int(pid), + integer(window[kCGWindowLayer as String]) == normalLayer, + let number = integer(window[kCGWindowNumber as String]), + let id = CGWindowID(exactly: number), + let bounds = window[kCGWindowBounds as String] as? [String: Any], + let width = integer(bounds["Width"]), + let height = integer(bounds["Height"]), + width > 0, height > 0 + else { + return nil + } + + return CaptureWindow( + id: id, + title: window[kCGWindowName as String] as? String, + width: width, + height: height + ) + } + } + + /// One of the window server's numbers, whichever numeric type it arrives as. + /// + /// The list holds `CFNumber`s in untyped dictionaries. Bridged, those cast to + /// `Int` while the value is whole and only to `Double` otherwise, which is a + /// distinction window bounds can cross: a window on a scaled display sits at + /// fractional points. + private static func integer(_ value: Any?) -> Int? { + if let int = value as? Int { return int } + if let double = value as? Double { return Int(double) } + return nil + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Windows.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Windows.swift new file mode 100644 index 000000000..a2a5ab144 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Windows.swift @@ -0,0 +1,74 @@ +import ApplicationServices +import Foundation + +/// One of an application's windows. +struct WindowSummary: Encodable, Equatable { + let identifier: String? + let title: String? + + /// Whether this is the application's main window. + let main: Bool? + + let minimized: Bool? + + /// Position and size in screen coordinates. + /// + /// Included here, unlike in a tree, because a window's frame is what the + /// listing is for: which window is where, and how big. + let frame: String? +} + +/// Lists an application's windows. +/// +/// Separate from a tree walk because the useful facts about a window are its own — +/// which one is main, which is minimized, where it sits — rather than what it +/// contains. +enum Windows { + /// Attributes read for every window, in one batch. + static let batch = [ + kAXIdentifierAttribute, + kAXTitleAttribute, + kAXMainAttribute, + kAXMinimizedAttribute, + "AXFrame", + ] + + /// List the windows of the application owning `pid`. + static func read(pid: pid_t) throws(DriveError) -> [WindowSummary] { + guard ProcessTable.record(for: pid) != nil else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + + guard AXIsProcessTrusted() else { + throw DriveError( + kind: .notPermitted, + message: "not trusted to read another application's accessibility tree", + hint: DriveError.accessibilityHint + ) + } + + return list(of: AXElement.application(pid: pid)) + } + + /// The windows an application element reports. + /// + /// An application with no windows answers an empty list, which is a state a + /// running app can legitimately be in. + static func list<E: Element>(of app: E) -> [WindowSummary] { + return app.elements(kAXWindowsAttribute).map { window in + let text = window.read(batch).text + + return WindowSummary( + identifier: text[0], + title: text[1], + main: text[2].axFlag, + minimized: text[3].axFlag, + frame: text[4] + ) + } + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/jpdrive/main.swift b/apps/macos/Tools/jpdrive/Sources/jpdrive/main.swift new file mode 100644 index 000000000..7e1373e35 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/jpdrive/main.swift @@ -0,0 +1,3 @@ +import DriveKit + +Driver.run() diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ActTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ActTests.swift new file mode 100644 index 000000000..b5f8c9ba0 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ActTests.swift @@ -0,0 +1,249 @@ +import ApplicationServices +import Testing + +@testable import DriveKit + +@Suite("Act") +struct ActTests { + /// The case the driver exists for, and the one that was broken: the identifier + /// is on a leaf two levels below the element that owns selection. + @Test("select writes AXSelected on the row, not on the identified element") + func selectsTheOwningRow() throws { + let root = FakeElement.sidebar(rowCount: 3) + let step = Step.select(.init(identifier: "sidebar.row.1")) + + let result = try Act.run(step, in: root) + + #expect( + result + == StepResult( + step: "select", + identifier: "sidebar.row.1", + role: "AXRow", + confirmed: true + ) + ) + #expect(result.confirmed == true) + + let rows = try #require(root.children.first?.children) + #expect(rows[1].attributes[kAXSelectedAttribute] == "1") + + // The leaf that carried the identifier must not have been written to. It + // reports no AXSelected at all, and a driver that wrote there would report + // success while selecting nothing. + let leaf = try #require(rows[1].children.first?.children.first) + #expect(leaf.attributes[kAXSelectedAttribute] == nil) + } + + /// Selection reaches a row regardless of where it sits, which is what makes the + /// attribute write preferable to a synthesized click. + @Test("select reaches a row far down a long list") + func selectsADeepRow() throws { + let root = FakeElement.sidebar(rowCount: 1000) + + let result = try Act.run(.select(.init(identifier: "sidebar.row.987")), in: root) + + #expect(result.confirmed == true) + let rows = try #require(root.children.first?.children) + #expect(rows[987].attributes[kAXSelectedAttribute] == "1") + } + + @Test("select reports the identifier it could not find") + func reportsAMissingIdentifier() { + let root = FakeElement.sidebar(rowCount: 3) + + #expect(throws: DriveError.self) { + try Act.run(.select(.init(identifier: "sidebar.row.nope")), in: root) + } + } + + /// An element nothing in its chain can select is a failure, not a fallback onto + /// some other mechanism. + @Test("select fails when no ancestor accepts the write") + func failsWhenNothingIsSelectable() throws { + let leaf = FakeElement(role: "AXUnknown", identifier: "lonely") + let root = FakeElement(role: "AXApplication", children: [leaf]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.select(.init(identifier: "lonely")), in: root) + } + + #expect(error.kind == .notSelectable) + } + + /// A write the accessibility API refuses is reported, not silently treated as + /// an unconfirmed success. + @Test("select reports a refused write") + func reportsARefusedWrite() throws { + let root = FakeElement.sidebar(rowCount: 2) + let rows = try #require(root.children.first?.children) + rows[0].writeStatus = .cannotComplete + + let error = try #require(throws: DriveError.self) { + try Act.run(.select(.init(identifier: "sidebar.row.0")), in: root) + } + + #expect(error.kind == .writeFailed) + #expect(error.message.contains("cannot_complete")) + } + + /// A write can be accepted and do nothing. The step reports that as an + /// unconfirmed success rather than as a failure, because the distinction is + /// what tells a caller the mechanism stopped working. + @Test("select reports an accepted write that changed nothing") + func reportsAnIneffectiveWrite() throws { + let leaf = FakeElement(role: "AXUnknown", identifier: "row") + let row = FakeElement(role: "AXRow", settable: [kAXSelectedAttribute], children: [leaf]) + let root = FakeElement(role: "AXApplication", children: [row]) + row.ignoresWrites = true + + let result = try Act.run(.select(.init(identifier: "row")), in: root) + + #expect(result.role == "AXRow") + #expect( + result.confirmed == false, + "a write the target discarded must not report as confirmed" + ) + } + + /// A sidebar row is the case that makes `click` the wrong tool: the identified + /// element has no activation point, and `select` reaches it whether or not it + /// is on screen. + @Test("click fails on a row, which wants select instead") + func clickFailsOnARow() throws { + let root = FakeElement.sidebar(rowCount: 1) + + let error = try #require(throws: DriveError.self) { + try Act.run( + .click(.init(identifier: "sidebar.row.0")), in: root, poster: FakePoster()) + } + + #expect(error.kind == .notClickable) + } + + @Test("press performs AXPress on the identified element") + func pressesTheElement() throws { + let item = FakeElement( + role: "AXMenuItem", + identifier: "terminate:", + actions: ["AXCancel", "AXPress", "AXPick"] + ) + let root = FakeElement(role: "AXApplication", children: [item]) + + let result = try Act.run(.press(.init(identifier: "terminate:")), in: root) + + #expect(item.performed == ["AXPress"]) + #expect(result.step == "press") + #expect(result.role == "AXMenuItem") + } + + /// Nothing readable says a press worked, so the step must not claim it did. + /// Reporting `true` here would be the one dishonest field in the output. + @Test("press reports no confirmation") + func pressDoesNotClaimConfirmation() throws { + let item = FakeElement(role: "AXButton", identifier: "go", actions: ["AXPress"]) + let root = FakeElement(role: "AXApplication", children: [item]) + + let result = try Act.run(.press(.init(identifier: "go")), in: root) + + #expect(result.confirmed == nil) + } + + /// A sidebar row is the case this catches: it advertises no actions at all, so + /// the error points at the step that does work on it. + @Test("press fails on an element that does not accept it") + func pressFailsWithoutTheAction() throws { + let root = FakeElement.sidebar(rowCount: 1) + + let error = try #require(throws: DriveError.self) { + try Act.run(.press(.init(identifier: "sidebar.row.0")), in: root) + } + + #expect(error.kind == .actionUnsupported) + #expect(error.hint?.contains("select") == true) + // The press must not have been attempted anyway. + let leaf = root.children.first?.children.first?.children.first?.children.first + #expect(leaf?.performed.isEmpty == true) + } + + /// An element with other actions gets told what it does accept, which is how a + /// script author finds the right verb without dumping the tree. + @Test("press names the actions an element does accept") + func pressNamesAvailableActions() throws { + let item = FakeElement(role: "AXRow", identifier: "row", actions: ["AXShowMenu"]) + let root = FakeElement(role: "AXApplication", children: [item]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.press(.init(identifier: "row")), in: root) + } + + #expect(error.hint?.contains("AXShowMenu") == true) + } + + /// `press` is a shorthand for `perform` with `AXPress`, and must keep saying so + /// in its result rather than reporting the mechanism underneath. + @Test("press names itself, not the general mechanism") + func pressNamesItself() throws { + let item = FakeElement(role: "AXButton", identifier: "go", actions: ["AXPress"]) + let root = FakeElement(role: "AXApplication", children: [item]) + + let result = try Act.run(.press(.init(identifier: "go")), in: root) + + #expect(result.step == "press") + } + + /// The escape hatch for the actions with no step of their own. A text field + /// offers `AXConfirm` and no `AXPress`, so this is the only way to reach it. + @Test("perform runs any action the element advertises") + func performsANamedAction() throws { + let field = FakeElement( + role: "AXTextField", + identifier: "sidebar.filter", + actions: ["AXShowMenu", "AXConfirm"] + ) + let root = FakeElement(role: "AXApplication", children: [field]) + + let result = try Act.run( + .perform(.init(identifier: "sidebar.filter", action: "AXConfirm")), + in: root + ) + + #expect(field.performed == ["AXConfirm"]) + #expect(result.step == "perform") + } + + @Test("perform fails on an action the element does not advertise") + func performRejectsAnUnknownAction() throws { + let field = FakeElement( + role: "AXTextField", + identifier: "sidebar.filter", + actions: ["AXConfirm"] + ) + let root = FakeElement(role: "AXApplication", children: [field]) + + let error = try #require(throws: DriveError.self) { + try Act.run( + .perform(.init(identifier: "sidebar.filter", action: "AXPress")), + in: root + ) + } + + #expect(error.kind == .actionUnsupported) + #expect(error.hint == "it accepts: AXConfirm") + #expect(field.performed.isEmpty) + } + + @Test("press reports a refused action") + func pressReportsARefusal() throws { + let item = FakeElement(role: "AXButton", identifier: "go", actions: ["AXPress"]) + item.performStatus = .cannotComplete + let root = FakeElement(role: "AXApplication", children: [item]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.press(.init(identifier: "go")), in: root) + } + + #expect(error.kind == .actionFailed) + #expect(error.message.contains("cannot_complete")) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ArgumentsTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ArgumentsTests.swift new file mode 100644 index 000000000..41534c061 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ArgumentsTests.swift @@ -0,0 +1,201 @@ +import Testing + +@testable import DriveKit + +@Suite("Arguments") +struct ArgumentsTests { + @Test("doctor takes an optional pid") + func doctorPid() throws { + guard case .doctor(let pid) = try Arguments.parse(["doctor", "--pid", "42"]) else { + Issue.record("expected a doctor command") + return + } + #expect(pid == 42) + + guard case .doctor(let none) = try Arguments.parse(["doctor"]) else { + Issue.record("expected a doctor command") + return + } + #expect(none == nil) + } + + @Test("tree defaults its bounds") + func treeDefaults() throws { + guard case .tree(let options) = try Arguments.parse(["tree", "--pid", "42"]) else { + Issue.record("expected a tree command") + return + } + + #expect(options.pid == 42) + #expect(options.identifierPrefix == nil) + #expect(options.maxMatches == Arguments.defaultMatches) + #expect(options.maxDepth == Arguments.defaultDepth) + #expect(options.maxSiblings == Arguments.defaultSiblings) + #expect(!options.frames) + } + + @Test("tree takes every bound") + func treeFlags() throws { + let parsed = try Arguments.parse([ + "tree", "--pid", "42", "--identifier", "sidebar.", "--max-matches", "1", + "--depth", "3", "--max-siblings", "0", "--frames", + ]) + + guard case .tree(let options) = parsed else { + Issue.record("expected a tree command") + return + } + + #expect(options.identifierPrefix == "sidebar.") + #expect(options.maxMatches == 1) + #expect(options.maxDepth == 3) + #expect(options.maxSiblings == 0) + #expect(options.frames) + } + + /// Zero means "every sibling", which is a different thing from the cap being + /// unset, so it has to survive parsing rather than be rejected as non-positive. + @Test("max-siblings accepts zero for no cap") + func zeroSiblingsIsAllowed() throws { + guard + case .dump(let options) = try Arguments.parse([ + "dump", "--pid", "1", "--max-siblings", "0", + ]) + else { + Issue.record("expected a dump command") + return + } + + #expect(options.maxSiblings == 0) + } + + @Test("windows takes only a pid") + func windowsPid() throws { + guard case .windows(let pid) = try Arguments.parse(["windows", "--pid", "42"]) else { + Issue.record("expected a windows command") + return + } + #expect(pid == 42) + } + + @Test("windowid takes only a pid") + func windowidPid() throws { + guard case .windowid(let pid) = try Arguments.parse(["windowid", "--pid", "42"]) else { + Issue.record("expected a windowid command") + return + } + #expect(pid == 42) + } + + /// A menu bar is small and every item in it is a thing to press, so the sibling + /// cap that keeps a thousand-row list readable would only hide verbs here. + @Test("menu walks every sibling by default") + func menuHasNoSiblingCap() throws { + guard case .menu(let pid, let options) = try Arguments.parse(["menu", "--pid", "42"]) + else { + Issue.record("expected a menu command") + return + } + + #expect(pid == 42) + #expect(options.maxSiblings == 0) + } + + @Test("act decodes a step") + func actStep() throws { + let parsed = try Arguments.parse([ + "act", "--pid", "42", "--json", #"{"select":{"identifier":"sidebar.row.7"}}"#, + ]) + + guard case .act(let step, let pid) = parsed else { + Issue.record("expected an act command") + return + } + + #expect(pid == 42) + guard case .select(let target) = step else { + Issue.record("expected a select step") + return + } + #expect(target.identifier == "sidebar.row.7") + } + + /// Every field of a step is spelled the way the tool definition documents it, + /// and a mismatch is silent: an unrecognised key decodes as absent, so a wait + /// given a short timeout would wait the default instead and the run would look + /// merely slow. + @Test("act decodes every field of a wait") + func actWaitFields() throws { + let parsed = try Arguments.parse([ + "act", "--pid", "42", "--json", + #"{"wait_for":{"identifier":"transcript.scroll","under":"sidebar.list","timeout_ms":1500,"interval_ms":25}}"#, + ]) + + guard case .act(let step, _) = parsed, case .waitFor(let target) = step else { + Issue.record("expected a wait_for step") + return + } + + #expect(target.identifier == "transcript.scroll") + #expect(target.under == "sidebar.list") + #expect(target.timeoutMs == 1500) + #expect(target.intervalMs == 25) + } + + @Test( + "a step naming no known verb is rejected", + arguments: [ + #"{"nope":{"identifier":"x"}}"#, + #"{"wait":{"identifier":"x"}}"#, + #"{}"#, + #"not json"#, + #"{"select":{}}"#, + ] + ) + func rejectsAMalformedStep(json: String) throws { + let error = try #require(throws: DriveError.self) { + try Arguments.parse(["act", "--pid", "1", "--json", json]) + } + + #expect(error.kind == .badUsage) + } + + @Test( + "a command missing its pid is rejected", + arguments: [ + ["tree"], ["dump"], ["windows"], ["windowid"], ["menu"], ["act", "--json", "{}"], + ] + ) + func requiresAPid(arguments: [String]) throws { + let error = try #require(throws: DriveError.self) { + try Arguments.parse(arguments) + } + + #expect(error.kind == .badUsage) + } + + /// An empty command substitution is the shape this most often takes: + /// `--pid $(pgrep -f JP.app)` expands to nothing when the app is not running, + /// leaving the flag with no value. + @Test("a pid flag with no value is rejected with a usable hint") + func rejectsAMissingPidValue() throws { + let error = try #require(throws: DriveError.self) { + try Arguments.parse(["doctor", "--pid"]) + } + + #expect(error.kind == .badUsage) + #expect(error.hint?.contains("pgrep") == true) + } + + @Test( + "unknown input is rejected", + arguments: [["fly", "--pid", "1"], ["tree", "--pid", "1", "--nope"], []] + ) + func rejectsUnknownInput(arguments: [String]) throws { + let error = try #require(throws: DriveError.self) { + try Arguments.parse(arguments) + } + + #expect(error.kind == .badUsage) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ClickTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ClickTests.swift new file mode 100644 index 000000000..d9986c121 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ClickTests.swift @@ -0,0 +1,115 @@ +import ApplicationServices +import Testing + +@testable import DriveKit + +@Suite("Act.click") +struct ClickTests { + /// A button inside a window, which is the shape a click needs: something with a + /// point, under something that can be raised. + private func app(activationPoint: CGPoint? = CGPoint(x: 120, y: 340)) -> FakeElement { + let button = FakeElement( + role: "AXButton", identifier: "toolbar.open", actions: ["AXPress"]) + if let activationPoint { + button.points[AXElement.activationPoint] = activationPoint + } + + let window = FakeElement( + role: kAXWindowRole, + identifier: "workspace-AppWindow-1", + actions: [kAXRaiseAction], + children: [button] + ) + + return FakeElement(role: "AXApplication", children: [window]) + } + + @Test("clicks where the element says a click belongs") + func clicksTheActivationPoint() throws { + let root = app() + let poster = FakePoster() + + let result = try Act.run( + .click(.init(identifier: "toolbar.open")), + in: root, + poster: poster + ) + + #expect(poster.clicks == [CGPoint(x: 120, y: 340)]) + #expect(result.step == "click") + #expect(result.role == "AXButton") + #expect(result.point == "120.0,340.0") + } + + /// The event goes to whatever occupies the coordinate, so a window behind + /// another one would have its click swallowed. Raising is what makes the + /// coordinate mean the element that named it. + @Test("raises the window before clicking") + func raisesTheWindowFirst() throws { + let root = app() + let window = try #require(root.children.first) + + _ = try Act.run( + .click(.init(identifier: "toolbar.open")), in: root, poster: FakePoster()) + + #expect(window.performed == [kAXRaiseAction]) + } + + /// A sidebar row has no activation point of its own, and pointing at `select` + /// is more use than clicking at the origin would be. + @Test("fails on an element with nowhere to click") + func failsWithoutAnActivationPoint() throws { + let poster = FakePoster() + + let error = try #require(throws: DriveError.self) { + try Act.run( + .click(.init(identifier: "toolbar.open")), + in: app(activationPoint: nil), + poster: poster + ) + } + + #expect(error.kind == .notClickable) + #expect(error.hint?.contains("select") == true) + #expect(poster.clicks.isEmpty, "nothing may be clicked when there is no point to click") + } + + @Test("reports a click that could not be posted") + func reportsAFailedPost() throws { + let poster = FakePoster() + poster.succeeds = false + + let error = try #require(throws: DriveError.self) { + try Act.run(.click(.init(identifier: "toolbar.open")), in: app(), poster: poster) + } + + #expect(error.kind == .actionFailed) + #expect(error.message.contains("120.0,340.0")) + } + + @Test("reports an identifier it could not find") + func reportsAMissingIdentifier() throws { + let poster = FakePoster() + + let error = try #require(throws: DriveError.self) { + try Act.run(.click(.init(identifier: "nope")), in: app(), poster: poster) + } + + #expect(error.kind == .identifierNotFound) + #expect(poster.clicks.isEmpty) + } + + /// An element outside any window still has a point, and clicking it is better + /// than refusing because there was nothing to raise. + @Test("clicks without a window to raise") + func clicksWithoutAWindow() throws { + let element = FakeElement(role: "AXButton", identifier: "loose") + element.points[AXElement.activationPoint] = CGPoint(x: 1, y: 2) + let root = FakeElement(role: "AXApplication", children: [element]) + let poster = FakePoster() + + _ = try Act.run(.click(.init(identifier: "loose")), in: root, poster: poster) + + #expect(poster.clicks == [CGPoint(x: 1, y: 2)]) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/DragTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/DragTests.swift new file mode 100644 index 000000000..93e471d96 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/DragTests.swift @@ -0,0 +1,229 @@ +import ApplicationServices +import Testing + +@testable import DriveKit + +@Suite("Act.drag") +struct DragTests { + /// A window with a known frame, which is all a drag needs: somewhere to + /// measure fractions against, and something to raise. + private func app( + origin: CGPoint? = CGPoint(x: 100, y: 200), + size: CGSize? = CGSize(width: 800, height: 600) + ) -> FakeElement { + let window = FakeElement( + role: kAXWindowRole, + identifier: "workspace-AppWindow-1", + actions: [kAXRaiseAction] + ) + if let origin { + window.points[kAXPositionAttribute] = origin + } + if let size { + window.sizes[kAXSizeAttribute] = size + } + + return FakeElement(role: "AXApplication", children: [window]) + } + + private func step( + from: (Double, Double), + to: (Double, Double), + steps: Int? = nil, + pauseMs: Int? = nil + ) -> Step { + .drag( + .init( + identifier: "workspace-AppWindow-1", + from: .init(dx: from.0, dy: from.1), + to: .init(dx: to.0, dy: to.1), + steps: steps, + pauseMs: pauseMs + ) + ) + } + + /// Fractions are resolved against the element's own frame, so a script says + /// "the right edge, halfway down" rather than a screen coordinate that stops + /// being right the moment the window moves. + @Test("resolves fractional offsets against the element's frame") + func resolvesOffsets() throws { + let poster = FakePoster() + + _ = try Act.run( + step(from: (1.0, 0.5), to: (0.5, 0.5), steps: 2), in: app(), poster: poster) + + // Right edge, halfway down: 100 + 800, 200 + 300. Halfway across: 100 + 400. + #expect( + poster.drags == [ + [ + CGPoint(x: 900, y: 500), + CGPoint(x: 700, y: 500), + CGPoint(x: 500, y: 500), + ] + ] + ) + } + + /// The step exists to produce many frames rather than one jump, so the count + /// is asserted rather than assumed: a drag delivered as a single move cannot + /// show what a view does *during* a gesture, which is the whole reason for it. + @Test("posts one move per step, plus the press") + func postsOneMovePerStep() throws { + let poster = FakePoster() + + let result = try Act.run( + step(from: (0, 0), to: (1, 0), steps: 12), in: app(), poster: poster) + + #expect(poster.drags.first?.count == 13) + #expect(result.moves == 12) + #expect(result.step == "drag") + #expect(result.role == kAXWindowRole) + } + + @Test("defaults to enough moves to be a gesture") + func defaultsToAGesture() throws { + let poster = FakePoster() + + _ = try Act.run(step(from: (0, 0), to: (1, 1)), in: app(), poster: poster) + + #expect(poster.drags.first?.count == 25) + #expect(poster.pauses == [.milliseconds(8)]) + } + + @Test("honours a stated pause between moves") + func honoursThePause() throws { + let poster = FakePoster() + + _ = try Act.run( + step(from: (0, 0), to: (1, 1), steps: 3, pauseMs: 40), in: app(), poster: poster) + + #expect(poster.pauses == [.milliseconds(40)]) + } + + /// A drag of zero steps is a click with extra words. Clamped rather than + /// rejected, so a caller computing the count from a distance cannot produce a + /// path with nothing in it. + @Test("clamps a step count below one") + func clampsZeroSteps() throws { + let poster = FakePoster() + + _ = try Act.run( + step(from: (0, 0), to: (1, 1), steps: 0), in: app(), poster: poster) + + #expect(poster.drags.first?.count == 2) + } + + /// The events land on whatever occupies the coordinates, so a window behind + /// another would have the gesture swallowed. + @Test("raises the window before dragging") + func raisesTheWindowFirst() throws { + let root = app() + let window = try #require(root.children.first) + + _ = try Act.run(step(from: (1, 0.5), to: (0.5, 0.5)), in: root, poster: FakePoster()) + + #expect(window.performed == [kAXRaiseAction]) + } + + /// Raising alone is not enough, and this is the assertion that says so. + /// + /// `AXRaise` orders a window forward within its own application; the ordering + /// *between* applications follows activation. A drag posted at a background + /// window's coordinates without this was received by the frontmost terminal + /// instead — measured, and the reason the step takes focus. + @Test("brings the application forward before dragging") + func activatesTheApplication() throws { + let root = app() + + _ = try Act.run(step(from: (1, 0.5), to: (0.5, 0.5)), in: root, poster: FakePoster()) + + #expect(root.flag(kAXFrontmostAttribute) == true) + } + + /// A tree that is not a running application has nothing to activate, and a + /// gesture against one is still worth posting: every other assertion in this + /// file depends on that. + @Test("drags even when the application cannot be brought forward") + func dragsWithoutActivating() throws { + let root = app() + root.writeStatus = .cannotComplete + let poster = FakePoster() + + _ = try Act.run(step(from: (0, 0), to: (1, 1), steps: 2), in: root, poster: poster) + + #expect(poster.drags.first?.count == 3) + } + + @Test("fails on an element with no frame to measure") + func failsWithoutAFrame() throws { + let poster = FakePoster() + + let error = try #require(throws: DriveError.self) { + try Act.run(step(from: (0, 0), to: (1, 1)), in: app(size: nil), poster: poster) + } + + #expect(error.kind == .notClickable) + #expect(poster.drags.isEmpty, "nothing may be dragged across a frame that is not known") + } + + @Test("reports a drag that could not be posted") + func reportsAFailedPost() throws { + let poster = FakePoster() + poster.succeeds = false + + let error = try #require(throws: DriveError.self) { + try Act.run(step(from: (0, 0), to: (1, 1)), in: app(), poster: poster) + } + + #expect(error.kind == .actionFailed) + #expect(error.message.contains("100.0,200.0")) + } + + @Test("reports an identifier it could not find") + func reportsAMissingIdentifier() throws { + let poster = FakePoster() + + let error = try #require(throws: DriveError.self) { + try Act.run( + .drag( + .init( + identifier: "nope", + from: .init(dx: 0, dy: 0), + to: .init(dx: 1, dy: 1), + steps: nil, + pauseMs: nil + ) + ), + in: app(), + poster: poster + ) + } + + #expect(error.kind == .identifierNotFound) + #expect(poster.drags.isEmpty) + } + + /// Decoded from the wire, because the snake-cased key is spelled by hand and a + /// mismatch there reads as the default silently applying. + @Test("decodes a step from its written form") + func decodesFromJSON() throws { + let json = """ + {"drag": {"identifier": "transcript.text", "from": {"dx": 0.1, "dy": 0.2}, + "to": {"dx": 0.8, "dy": 0.6}, "steps": 6, "pause_ms": 15}} + """ + + let decoded = try JSONDecoder().decode(Step.self, from: Data(json.utf8)) + + guard case .drag(let target) = decoded else { + Issue.record("expected a drag step, got \(decoded)") + return + } + + #expect(target.identifier == "transcript.text") + #expect(target.from.dx == 0.1) + #expect(target.to.dy == 0.6) + #expect(target.steps == 6) + #expect(target.pauseMs == 15) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakeElement.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakeElement.swift new file mode 100644 index 000000000..aa8deef81 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakeElement.swift @@ -0,0 +1,173 @@ +import ApplicationServices + +@testable import DriveKit + +/// An accessibility element that is not one. +/// +/// Reference semantics on purpose: a step writes to an element and then reads it +/// back, and a test asserts on what was written. With a value type the write would +/// land on a copy and every such assertion would pass vacuously. +final class FakeElement: Element { + /// Attribute values by name. A name that is absent here reads as `nil`, which + /// is what the real implementation answers for an attribute the element does + /// not report. + var attributes: [String: String] + + /// Attribute names this element accepts writes for. + let settable: Set<String> + + var actions: [String] + var children: [FakeElement] + + /// Counts every call to ``read(_:)``, so a test can pin how much of a tree a + /// walk touched rather than only what it returned. + private(set) var reads = 0 + + /// What `setFlag` should answer, for exercising a refused write. + var writeStatus: AXError = .success + + /// Accept writes and discard them. + /// + /// The accessibility API lets a target answer `success` and then do nothing, + /// which is why a step reads back rather than trusting the status. Without this + /// there is no way to tell a driver that reads back from one that pretends to. + var ignoresWrites = false + + /// Called during every ``read(_:)``, after ``reads`` is incremented and before + /// children are handed back. + /// + /// This is how a test makes an element appear partway through a wait. A fixture + /// that has the element from the start cannot tell polling from a single lucky + /// look. + var onRead: ((FakeElement) -> Void)? + + /// Element-valued attributes, such as `AXWindows` and `AXMenuBar`. + var related: [String: [FakeElement]] = [:] + + /// Point-valued attributes, such as `AXActivationPoint`. + var points: [String: CGPoint] = [:] + + /// Size-valued attributes, such as `AXSize`. + var sizes: [String: CGSize] = [:] + + /// Actions performed on this element, in order. + private(set) var performed: [String] = [] + + /// What `perform` should answer, for exercising a refused action. + var performStatus: AXError = .success + + init( + role: String, + identifier: String? = nil, + label: String? = nil, + settable: Set<String> = [], + actions: [String] = [], + children: [FakeElement] = [] + ) { + self.attributes = [kAXRoleAttribute: role] + self.attributes[kAXIdentifierAttribute] = identifier + self.attributes[AXElement.attributedDescription] = label + self.settable = settable + self.actions = actions + self.children = children + } + + func read(_ names: [String]) -> Reading<FakeElement> { + reads += 1 + onRead?(self) + return Reading(text: names.map { attributes[$0] }, children: children) + } + + func isSettable(_ name: String) -> Bool { + return settable.contains(name) + } + + func flag(_ name: String) -> Bool? { + switch attributes[name] { + case "1": return true + case "0": return false + default: return nil + } + } + + func setFlag(_ name: String, _ value: Bool) -> AXError { + guard writeStatus == .success else { return writeStatus } + guard !ignoresWrites else { return .success } + attributes[name] = value ? "1" : "0" + return .success + } + + func setText(_ name: String, _ value: String) -> AXError { + guard writeStatus == .success else { return writeStatus } + guard !ignoresWrites else { return .success } + attributes[name] = value + return .success + } + + func perform(_ action: String) -> AXError { + guard performStatus == .success else { return performStatus } + performed.append(action) + return .success + } + + func point(_ name: String) -> CGPoint? { + return points[name] + } + + func size(_ name: String) -> CGSize? { + return sizes[name] + } + + func setSize(_ name: String, _ value: CGSize) -> AXError { + guard writeStatus == .success else { return writeStatus } + guard !ignoresWrites else { return .success } + sizes[name] = value + return .success + } + + func elements(_ name: String) -> [FakeElement] { + return related[name] ?? [] + } +} + +extension FakeElement { + /// The sidebar shape this app actually produces, at whatever size a test needs. + /// + /// Three elements per conversation, with the identifier on the leaf and + /// `AXSelected` writable only on the row. Reproducing that here is the point: + /// the driver has to address one element and act on another. + static func sidebar(rowCount: Int) -> FakeElement { + let rows = (0..<rowCount).map { index in + FakeElement( + role: "AXRow", + settable: [kAXSelectedAttribute], + actions: ["AXShowDefaultUI"], + children: [ + FakeElement( + role: "AXCell", + children: [ + FakeElement( + role: "AXUnknown", + identifier: "sidebar.row.\(index)", + label: "Conversation \(index), 4 events" + ) + ] + ) + ] + ) + } + + return FakeElement( + role: "AXApplication", + children: [ + FakeElement( + role: "AXOutline", + identifier: "sidebar.list", + label: "Conversations", + actions: ["AXShowMenu"], + children: rows + ) + ] + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakePoster.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakePoster.swift new file mode 100644 index 000000000..808d79006 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakePoster.swift @@ -0,0 +1,35 @@ +import ApplicationServices + +@testable import DriveKit + +/// Records clicks and drags instead of posting them. +/// +/// A real click goes to the window server and lands on whatever occupies the +/// coordinate, so the only part a test can hold still is where the driver aimed. +final class FakePoster: EventPoster { + /// Every point clicked, in order. + private(set) var clicks: [CGPoint] = [] + + /// Every drag's path, in order. + private(set) var drags: [[CGPoint]] = [] + + /// The pause each drag was asked to wait between moves. + private(set) var pauses: [Duration] = [] + + /// What `click` and `drag` should answer, for exercising a post that could + /// not be built. + var succeeds = true + + func click(at point: CGPoint) -> Bool { + guard succeeds else { return false } + clicks.append(point) + return true + } + + func drag(through path: [CGPoint], pausing pause: Duration) -> Bool { + guard succeeds else { return false } + drags.append(path) + pauses.append(pause) + return true + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/MenuTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/MenuTests.swift new file mode 100644 index 000000000..002559049 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/MenuTests.swift @@ -0,0 +1,272 @@ +import ApplicationServices +import Testing + +@testable import DriveKit + +/// The menu bar's own walk is [`Tree`](Tree)'s, already covered by `TreeTests`. +/// What is specific here is the root: the menu bar hangs off an attribute of the +/// application rather than sitting in its children, and every level of it should be +/// reported rather than capped. +@Suite("Menu") +struct MenuTests { + /// The whole menu bar, with no cap, because every item in it is something a + /// script might press. + private func options() -> TreeOptions { + return TreeOptions( + pid: 0, + identifierPrefix: nil, + maxMatches: 100, + maxDepth: 20, + maxSiblings: 0, + frames: false + ) + } + + /// An application whose menu bar hangs off the attribute the real one uses. + private func app() -> FakeElement { + let app = FakeElement(role: "AXApplication") + app.related[kAXMenuBarAttribute] = [menuBar()] + return app + } + + private func menuBar() -> FakeElement { + return FakeElement( + role: "AXMenuBar", + children: [ + FakeElement( + role: "AXMenuBarItem", + label: "File", + children: [ + FakeElement( + role: "AXMenu", + children: [ + FakeElement( + role: "AXMenuItem", + identifier: "performClose:", + label: "Close", + actions: ["AXCancel", "AXPress", "AXPick"] + ), + FakeElement( + role: "AXMenuItem", + identifier: "closeAll:", + label: "Close All", + actions: ["AXCancel", "AXPress", "AXPick"] + ), + ] + ) + ] + ) + ] + ) + } + + @Test("every menu item is reported, with the action that activates it") + func reportsEveryItem() throws { + let tree = try #require(Tree.walk(from: menuBar(), options: options())) + + #expect(tree.role == "AXMenuBar") + let items = try #require(tree.children.first?.children.first?.children) + #expect(items.count == 2) + #expect(items.map(\.identifier) == ["performClose:", "closeAll:"]) + #expect(items[0].actions.contains("AXPress")) + #expect(tree.children.first?.children.first?.elidedChildren == nil) + } + + /// Menu items advertise `AXPress` where a list row advertises nothing, so they + /// are the case `press` was built for. + @Test("a menu item can be pressed by identifier") + func pressesAMenuItem() throws { + let bar = menuBar() + + let result = try Act.run(.press(.init(identifier: "closeAll:")), in: bar) + + #expect(result.role == "AXMenuItem") + let items = try #require(bar.children.first?.children.first?.children) + #expect(items[1].performed == ["AXPress"]) + #expect(items[0].performed.isEmpty, "only the addressed item may be pressed") + } + + /// The path names the two titled levels a user sees and skips the `AXMenu` + /// between them, because that container has no title to name. + @Test("a titled path resolves through the intervening menu") + func resolvesATitledPath() throws { + let root = app() + + let result = try Act.run(.menu(.init(path: ["File", "Close All"])), in: root) + + #expect(result.step == "menu") + #expect(result.identifier == "File > Close All") + #expect(result.role == "AXMenuItem") + + let bar = try #require(root.elements(kAXMenuBarAttribute).first) + let items = try #require(bar.children.first?.children.first?.children) + #expect(items[1].performed == ["AXPress"]) + #expect(items[0].performed.isEmpty) + } + + /// The point of addressing by title: an item that moved to another menu keeps + /// its identifier, so only a path notices. The failure has to say what the + /// level does hold, or the test that catches the move cannot say what changed. + @Test("a path that does not resolve names what the level holds") + func reportsWhatTheLevelHolds() throws { + let error = try #require(throws: DriveError.self) { + try Act.run(.menu(.init(path: ["File", "Quit"])), in: app()) + } + + #expect(error.kind == .notFound) + #expect(error.message.contains("'File' holds no item titled 'Quit'")) + #expect(error.hint == "it holds: Close, Close All") + } + + /// A context menu's items cannot be addressed any other way: `SwiftUI` gives + /// every one of them the same selector name, so a title is all there is. + @Test("a path can start at the menu an element is showing") + func resolvesUnderAShownMenu() throws { + let item = FakeElement(role: "AXMenuItem", label: "Copy Link", actions: ["AXPress"]) + let owner = FakeElement( + role: "AXOutline", + identifier: "sidebar.list", + children: [ + FakeElement(role: "AXRow", identifier: "sidebar.row.0"), + FakeElement(role: "AXMenu", children: [item]), + ] + ) + let root = FakeElement(role: "AXApplication", children: [owner]) + + let result = try Act.run( + .menu(.init(path: ["Copy Link"], under: "sidebar.list")), in: root) + + #expect(result.step == "menu") + #expect(result.identifier == "Copy Link") + #expect(item.performed == ["AXPress"]) + } + + /// The menu closes as soon as the application deactivates, so "press an item + /// in it" fails far more often than "open it" does. The error has to name the + /// step that was missed. + @Test("a path under an element that shows no menu says how to open one") + func reportsAnUnshownMenu() throws { + let owner = FakeElement( + role: "AXOutline", + identifier: "sidebar.list", + children: [FakeElement(role: "AXRow", identifier: "sidebar.row.0")] + ) + let root = FakeElement(role: "AXApplication", children: [owner]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.menu(.init(path: ["Copy Link"], under: "sidebar.list")), in: root) + } + + #expect(error.kind == .notFound) + #expect(error.message == "sidebar.list is not showing a menu") + #expect(error.hint?.contains("AXShowMenu") == true) + } + + @Test("a missing top-level menu is reported against the bar") + func reportsAMissingTopLevelMenu() throws { + let error = try #require(throws: DriveError.self) { + try Act.run(.menu(.init(path: ["Edit", "Copy"])), in: app()) + } + + #expect(error.kind == .notFound) + #expect(error.message.contains("the menu bar holds no item titled 'Edit'")) + #expect(error.hint == "it holds: File") + } + + /// Stopping at a bar item addresses the menu, not an item in it, and pressing a + /// menu is not what the script meant. + @Test("a path stopping at a submenu is rejected") + func rejectsAPathToASubmenu() throws { + let error = try #require(throws: DriveError.self) { + try Act.run(.menu(.init(path: ["File"])), in: app()) + } + + #expect(error.kind == .actionUnsupported) + #expect(error.hint?.contains("name the item inside it") == true) + } + + @Test("an empty path is rejected") + func rejectsAnEmptyPath() throws { + let error = try #require(throws: DriveError.self) { + try Act.run(.menu(.init(path: [])), in: app()) + } + + #expect(error.kind == .badUsage) + } + + /// The reason every menu test would otherwise pass while the real thing did + /// nothing: AppKit disables every item acting on the front window or the + /// responder chain while the application is in the background, which a driven + /// app always is. + @Test("a menu step brings the application forward first") + func bringsTheApplicationForward() throws { + let root = app() + + _ = try Act.run(.menu(.init(path: ["File", "Close All"])), in: root) + + #expect(root.flag(kAXFrontmostAttribute) == true) + } + + /// An application already in front must not be written to: the write is what + /// steals focus, and a run of several menu steps would take it repeatedly. + @Test("an application already in front is left alone") + func leavesAFrontApplicationAlone() throws { + let root = app() + root.attributes[kAXFrontmostAttribute] = "1" + // Any write from here on fails, so a needless one fails the step. + root.writeStatus = .failure + + let result = try Act.run(.menu(.init(path: ["File", "Close All"])), in: root) + + #expect(result.identifier == "File > Close All") + } + + /// A disabled item swallows `AXPress` and answers success, so a step that + /// pressed it anyway would report having done something it did not do. + @Test("a disabled item is refused rather than pressed") + func refusesADisabledItem() throws { + let root = app() + let bar = try #require(root.elements(kAXMenuBarAttribute).first) + let items = try #require(bar.children.first?.children.first?.children) + items[1].attributes[kAXEnabledAttribute] = "0" + + let error = try #require(throws: DriveError.self) { + try Act.run( + .menu(.init(path: ["File", "Close All"])), + in: root, + activation: .milliseconds(1) + ) + } + + #expect(error.kind == .disabled) + #expect(error.message == "'File > Close All' is disabled") + #expect(items[1].performed.isEmpty, "a disabled item must not be pressed") + } + + /// Most elements report no `AXEnabled` at all, and reading its absence as a + /// refusal would reject every one of them. + @Test("an item reporting no enabled state is pressed") + func pressesAnItemWithNoEnabledState() throws { + let root = app() + + let result = try Act.run( + .menu(.init(path: ["File", "Close All"])), + in: root, + activation: .milliseconds(1) + ) + + #expect(result.identifier == "File > Close All") + } + + /// The menu bar comes from the application's attribute. An app without one is a + /// real case, and it must not be reported as a missing menu item. + @Test("an application with no menu bar says so") + func reportsNoMenuBar() throws { + let error = try #require(throws: DriveError.self) { + try Act.run(.menu(.init(path: ["File"])), in: FakeElement(role: "AXApplication")) + } + + #expect(error.kind == .notFound) + #expect(error.message.contains("no menu bar")) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/PixelsTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/PixelsTests.swift new file mode 100644 index 000000000..28bef284e --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/PixelsTests.swift @@ -0,0 +1,231 @@ +import CoreGraphics +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers + +@testable import DriveKit + +/// Tests for reading a screenshot's pixels. +/// +/// Everything here works against a PNG written by the test, so none of it needs a +/// window server, a running app or a Screen Recording grant. +@Suite("Pixels") +struct PixelsTests { + /// A four-by-two image, written to a temporary file and removed afterwards. + /// + /// Rows top to bottom, each row left to right, as `#RRGGBB` strings. Written + /// through `CGImageDestination` so the file is a real PNG decoded by the same + /// path a screenshot takes. + private func withImage( + rows: [[String]], + _ body: (String) throws -> Void + ) throws { + let height = rows.count + let width = try #require(rows.first?.count) + + var bytes: [UInt8] = [] + for row in rows { + for hex in row { + let value = try #require(UInt32(hex.dropFirst(), radix: 16)) + bytes.append(UInt8((value >> 16) & 0xFF)) + bytes.append(UInt8((value >> 8) & 0xFF)) + bytes.append(UInt8(value & 0xFF)) + bytes.append(255) + } + } + + let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let provider = try #require(CGDataProvider(data: Data(bytes) as CFData)) + let image = try #require( + CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: width * 4, + space: space, + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + )) + + let path = NSTemporaryDirectory() + "/jpdrive-pixels-\(UUID().uuidString).png" + let url = URL(fileURLWithPath: path) as CFURL + let destination = try #require( + CGImageDestinationCreateWithURL(url, UTType.png.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, image, nil) + #expect(CGImageDestinationFinalize(destination)) + + defer { try? FileManager.default.removeItem(atPath: path) } + try body(path) + } + + /// Two colours across a row, which is the shape every real question takes: a + /// wide background, a narrow line, and the offset where one becomes the other. + @Test("collapses a row into runs of one colour") + func scansARow() throws { + try withImage(rows: [ + ["#FFFFFF", "#FFFFFF", "#DBDBDB", "#FFFFFF"], + ["#000000", "#000000", "#000000", "#000000"], + ]) { path in + let report = try Pixels.read( + PixelOptions(image: path, axis: .row, at: 0, from: nil, to: nil)) + + #expect(report.width == 4) + #expect(report.height == 2) + #expect(report.colorSpace == "sRGB") + #expect( + report.runs == [ + PixelRun(start: 0, count: 2, color: "#FFFFFF"), + PixelRun(start: 2, count: 1, color: "#DBDBDB"), + PixelRun(start: 3, count: 1, color: "#FFFFFF"), + ] + ) + } + } + + /// Rows are indexed down from the top, the way a screenshot is read, not up + /// from the bottom the way CoreGraphics draws. + @Test("counts rows down from the top") + func rowsCountFromTheTop() throws { + try withImage(rows: [ + ["#FF0000", "#FF0000"], + ["#00FF00", "#00FF00"], + ["#0000FF", "#0000FF"], + ]) { path in + let top = try Pixels.read( + PixelOptions(image: path, axis: .row, at: 0, from: nil, to: nil)) + let bottom = try Pixels.read( + PixelOptions(image: path, axis: .row, at: 2, from: nil, to: nil)) + + #expect(top.runs == [PixelRun(start: 0, count: 2, color: "#FF0000")]) + #expect(bottom.runs == [PixelRun(start: 0, count: 2, color: "#0000FF")]) + } + } + + @Test("collapses a column into runs of one colour") + func scansAColumn() throws { + try withImage(rows: [ + ["#FFFFFF", "#111111"], + ["#FFFFFF", "#111111"], + ["#222222", "#111111"], + ]) { path in + let report = try Pixels.read( + PixelOptions(image: path, axis: .column, at: 0, from: nil, to: nil)) + + #expect(report.scan == "column") + #expect( + report.runs == [ + PixelRun(start: 0, count: 2, color: "#FFFFFF"), + PixelRun(start: 2, count: 1, color: "#222222"), + ] + ) + } + } + + /// A window is nine hundred points wide and the interesting part is a few of + /// them, so a scan can be bounded. The offsets stay absolute, because they are + /// what gets compared against a frame from the accessibility tree. + @Test("bounds a scan and keeps the offsets absolute") + func boundsAScan() throws { + try withImage(rows: [["#FFFFFF", "#AAAAAA", "#BBBBBB", "#FFFFFF"]]) { path in + let report = try Pixels.read( + PixelOptions(image: path, axis: .row, at: 0, from: 1, to: 2)) + + #expect( + report.runs == [ + PixelRun(start: 1, count: 1, color: "#AAAAAA"), + PixelRun(start: 2, count: 1, color: "#BBBBBB"), + ] + ) + } + } + + /// Reading past the edge is a mistake worth reporting rather than clamping: a + /// silently moved scan answers a question nobody asked. + @Test("refuses a line outside the image") + func refusesALineOutside() throws { + try withImage(rows: [["#FFFFFF"]]) { path in + #expect(throws: DriveError.self) { + try Pixels.read( + PixelOptions(image: path, axis: .row, at: 7, from: nil, to: nil)) + } + } + } + + @Test("reports an image it cannot read") + func reportsAMissingImage() { + #expect(throws: DriveError.self) { + try Pixels.read( + PixelOptions( + image: "/no/such/screenshot.png", axis: .row, at: 0, from: nil, to: nil)) + } + } + + /// A translucent pixel carries its alpha, so it cannot be mistaken for an + /// opaque one of the same colour. + @Test("spells an opaque colour without alpha and a translucent one with it") + func spellsAlphaOnlyWhenItMatters() { + #expect(Pixel(red: 0xDB, green: 0xDB, blue: 0xDB, alpha: 255).hex == "#DBDBDB") + #expect(Pixel(red: 0xDB, green: 0xDB, blue: 0xDB, alpha: 128).hex == "#DBDBDB80") + } + + @Test("collapses an empty line into no runs") + func emptyLine() { + #expect(Pixels.runs(of: [], startingAt: 0).isEmpty) + } + + /// A screenshot is written in the display's profile, which is not the space a + /// palette constant was written in. Read raw, a `#DBDBDB` divider comes back + /// as something several steps off and looks like a bug in the app. + /// + /// The image here is tagged Display P3 and holds the P3 encoding of sRGB + /// `#DBDBDB`, so a reader that converts reports the value the palette names + /// and a reader that does not reports `#DBDBDB` itself — which is the wrong + /// answer, arrived at by leaving the numbers alone. + @Test("reports colours in sRGB whatever space the image is tagged with") + func convertsToSRGB() throws { + let p3 = try #require(CGColorSpace(name: CGColorSpace.displayP3)) + let sRGB = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let level = CGFloat(0xDB) / 255 + let grey = try #require( + CGColor(colorSpace: sRGB, components: [level, level, level, 1])) + let converted = try #require( + grey.converted(to: p3, intent: CGColorRenderingIntent.defaultIntent, options: nil)) + let parts = try #require(converted.components) + + let path = NSTemporaryDirectory() + "/jpdrive-p3-\(UUID().uuidString).png" + defer { try? FileManager.default.removeItem(atPath: path) } + + let bytes = parts.prefix(3).map { UInt8(($0 * 255).rounded()) } + [255] + let provider = try #require(CGDataProvider(data: Data(bytes) as CFData)) + let image = try #require( + CGImage( + width: 1, + height: 1, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: 4, + space: p3, + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + )) + + let url = URL(fileURLWithPath: path) as CFURL + let destination = try #require( + CGImageDestinationCreateWithURL(url, UTType.png.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, image, nil) + #expect(CGImageDestinationFinalize(destination)) + + let report = try Pixels.read( + PixelOptions(image: path, axis: .row, at: 0, from: nil, to: nil)) + + #expect(report.runs.first?.color == "#DBDBDB") + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ResizeTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ResizeTests.swift new file mode 100644 index 000000000..6240dcf08 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ResizeTests.swift @@ -0,0 +1,103 @@ +import ApplicationServices +import Foundation +import Testing + +@testable import DriveKit + +/// Tests for the `resize` step. +/// +/// A window is the only thing that accepts a write to `AXSize`, and resizing is +/// the one interaction a driver cannot reach any other way: a drag of a window's +/// edge has to be synthesized, and a synthesized drag needs the window frontmost. +@Suite("Resize") +struct ResizeTests { + /// A window that accepts a size, starting at `size`. + private func window(_ size: CGSize, settable: Bool = true) -> FakeElement { + let window = FakeElement( + role: "AXWindow", + identifier: "the-window", + settable: settable ? [kAXSizeAttribute] : [] + ) + window.sizes[kAXSizeAttribute] = size + return window + } + + private func step(width: Double, height: Double) -> Step { + .resize(Step.SizeTarget(identifier: "the-window", width: width, height: height)) + } + + @Test("writes the size it was asked for") + func writesTheSize() throws { + let window = self.window(CGSize(width: 900, height: 450)) + + let result = try Act.run(step(width: 1400, height: 900), in: window) + + #expect(window.sizes[kAXSizeAttribute] == CGSize(width: 1400, height: 900)) + #expect(result.step == "resize") + #expect(result.role == "AXWindow") + #expect(result.confirmed == true) + #expect(result.size == "1400x900") + } + + /// A window clamps to its own minimum and maximum, so the write succeeds and + /// the window lands somewhere else. Reporting what it reached is the whole + /// reason the step reads the size back instead of echoing the request. + @Test("reports the size it reached when the window clamps the request") + func reportsAClampedSize() throws { + let window = self.window(CGSize(width: 900, height: 450)) + window.ignoresWrites = true + + let result = try Act.run(step(width: 200, height: 100), in: window) + + #expect(result.confirmed == false) + #expect(result.size == "900x450") + } + + /// Most elements inside a window do not accept a size, and a step that asked + /// anyway would report success having changed nothing. + @Test("refuses an element that does not accept a size") + func refusesAnUnsizableElement() { + let element = window(CGSize(width: 900, height: 450), settable: false) + + #expect(throws: DriveError.self) { + try Act.run(step(width: 1400, height: 900), in: element) + } + } + + @Test("reports an identifier that is not in the tree") + func reportsAMissingIdentifier() { + let other = FakeElement(role: "AXWindow", identifier: "something-else") + + #expect(throws: DriveError.self) { + try Act.run(step(width: 1400, height: 900), in: other) + } + } + + @Test("surfaces a write the accessibility API refused") + func surfacesARefusedWrite() { + let window = self.window(CGSize(width: 900, height: 450)) + window.writeStatus = .cannotComplete + + #expect(throws: DriveError.self) { + try Act.run(step(width: 1400, height: 900), in: window) + } + } + + /// The step arrives as JSON from the driver's caller, so the spelling of its + /// keys is part of the contract. + @Test("decodes the step a caller writes") + func decodesTheStep() throws { + let json = #"{"resize":{"identifier":"w","width":1400,"height":900}}"# + + let decoded = try JSONDecoder().decode(Step.self, from: Data(json.utf8)) + + guard case .resize(let target) = decoded else { + Issue.record("expected a resize step, got \(decoded)") + return + } + + #expect(target.identifier == "w") + #expect(target.width == 1400) + #expect(target.height == 900) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/TreeTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/TreeTests.swift new file mode 100644 index 000000000..fb3677812 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/TreeTests.swift @@ -0,0 +1,164 @@ +import Testing + +@testable import DriveKit + +@Suite("Tree") +struct TreeTests { + /// Options with the bounds wide open, so a test names only what it is about. + private func options( + prefix: String? = nil, + maxMatches: Int = 100, + maxDepth: Int = 20, + maxSiblings: Int = 0, + frames: Bool = false + ) -> TreeOptions { + return TreeOptions( + pid: 0, + identifierPrefix: prefix, + maxMatches: maxMatches, + maxDepth: maxDepth, + maxSiblings: maxSiblings, + frames: frames + ) + } + + @Test("an unfiltered walk keeps every element") + func keepsEverythingUnfiltered() throws { + let root = FakeElement.sidebar(rowCount: 2) + + let tree = try #require(Tree.walk(from: root, options: options())) + + #expect(tree.role == "AXApplication") + let outline = try #require(tree.children.first) + #expect(outline.identifier == "sidebar.list") + #expect(outline.children.count == 2) + } + + /// The bug this replaced: with a sibling cap in force, a search for a row past + /// the cap found nothing, because the cap dropped it before the filter saw it. + @Test("a filtered walk finds a match past the sibling cap") + func filterOutrunsTheSiblingCap() throws { + let root = FakeElement.sidebar(rowCount: 50) + + let tree = try #require( + Tree.walk(from: root, options: options(prefix: "sidebar.row.42", maxSiblings: 5)) + ) + + let leaf = tree.children.first?.children.first?.children.first?.children.first + #expect(leaf?.identifier == "sidebar.row.42") + } + + /// Ancestors are kept so a match can be located, but only the ones leading to it. + @Test("a filtered walk drops branches holding no match") + func prunesUnmatchedBranches() throws { + let root = FakeElement( + role: "AXApplication", + children: [ + FakeElement(role: "AXWindow", identifier: "other.window"), + FakeElement( + role: "AXOutline", + identifier: "sidebar.list", + children: [FakeElement(role: "AXRow", identifier: "sidebar.row.0")] + ), + ] + ) + + let tree = try #require(Tree.walk(from: root, options: options(prefix: "sidebar."))) + + #expect(tree.children.count == 1) + #expect(tree.children.first?.identifier == "sidebar.list") + + // The dropped window is still counted. A filtered read that silently + // showed one child of two would have the reader believe the application + // has one. + #expect(tree.elidedChildren == 1) + } + + @Test("a filtered walk with no match returns nothing") + func returnsNothingWhenNothingMatches() { + let root = FakeElement.sidebar(rowCount: 3) + + #expect(Tree.walk(from: root, options: options(prefix: "nope.")) == nil) + } + + /// The budget is the bound that keeps a prefix search off the whole tree, so it + /// has to actually stop the walk rather than only trim the output. + @Test("the match budget stops the walk") + func budgetStopsTheWalk() throws { + let root = FakeElement.sidebar(rowCount: 500) + + let tree = try #require( + Tree.walk(from: root, options: options(prefix: "sidebar.", maxMatches: 3)) + ) + + // One match is the outline itself, leaving two rows. + let outline = try #require(tree.children.first) + #expect(outline.children.count == 2) + + // Reads, not results: a budget that trimmed the output while still visiting + // every element would pass an assertion on the tree alone. + let rows = try #require(root.children.first?.children) + #expect(rows.dropFirst(3).allSatisfy { $0.reads == 0 }) + } + + @Test("the sibling cap reports what it skipped") + func capReportsElidedChildren() throws { + let root = FakeElement.sidebar(rowCount: 10) + + let tree = try #require(Tree.walk(from: root, options: options(maxSiblings: 4))) + + let outline = try #require(tree.children.first) + #expect(outline.children.count == 4) + #expect(outline.elidedChildren == 6) + } + + @Test("a complete level reports no elision") + func noElisionWhenComplete() throws { + let root = FakeElement.sidebar(rowCount: 3) + + let tree = try #require(Tree.walk(from: root, options: options(maxSiblings: 10))) + + #expect(tree.children.first?.elidedChildren == nil) + } + + /// The count is what separates a node stopped at the depth limit from a leaf. + /// Without it the two render identically and a reader concludes the element + /// has no children. + @Test("the depth limit stops the descent and reports what it did not reach") + func depthLimitStopsDescent() throws { + let root = FakeElement.sidebar(rowCount: 2) + + let tree = try #require(Tree.walk(from: root, options: options(maxDepth: 1))) + + #expect(tree.children.first?.children.isEmpty == true) + #expect(tree.children.first?.elidedChildren == 2) + } + + /// Frames move whenever a window moves or a list scrolls, so they stay out + /// unless asked for. + @Test("frames are omitted by default") + func framesAreOptIn() throws { + let root = FakeElement(role: "AXWindow") + root.attributes["AXFrame"] = "0.0,0.0 100.0x100.0" + + let without = try #require(Tree.walk(from: root, options: options())) + #expect(without.frame == nil) + + let with = try #require(Tree.walk(from: root, options: options(frames: true))) + #expect(with.frame == "0.0,0.0 100.0x100.0") + } + + /// An attribute the element does not report must arrive as absent, not as the + /// text of whatever error the accessibility API answered with. + @Test("absent attributes are absent, not error text") + func absentAttributesAreNull() throws { + let root = FakeElement(role: "AXCell") + + let tree = try #require(Tree.walk(from: root, options: options())) + + #expect(tree.identifier == nil) + #expect(tree.label == nil) + #expect(tree.value == nil) + #expect(tree.enabled == nil) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/TypeTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/TypeTests.swift new file mode 100644 index 000000000..436ce5ab2 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/TypeTests.swift @@ -0,0 +1,186 @@ +import ApplicationServices +import Testing + +@testable import DriveKit + +@Suite("Act.type") +struct TypeTests { + /// A text field shaped like the one SwiftUI produces: `AXValue` writable, + /// `AXPress` absent. + private func field(identifier: String = "sidebar.filter") -> FakeElement { + return FakeElement( + role: "AXTextField", + identifier: identifier, + settable: [kAXValueAttribute, kAXFocusedAttribute], + actions: ["AXShowMenu", "AXConfirm"] + ) + } + + @Test("writes the text into the field") + func writesTheText() throws { + let field = field() + let root = FakeElement(role: "AXApplication", children: [field]) + + let result = try Act.run( + .type(.init(identifier: "sidebar.filter", text: "driving")), + in: root + ) + + #expect(field.attributes[kAXValueAttribute] == "driving") + #expect(result.step == "type") + #expect(result.role == "AXTextField") + #expect(result.confirmed == true) + } + + /// Writing the value alone changes the text a `SwiftUI` field shows without the + /// binding behind it noticing, so the application carries on as though nothing + /// was typed. The confirm is what the application actually observes, and a + /// `type` that skipped it would report success having done nothing. + @Test("commits the edit through the field's confirm action") + func commitsTheEdit() throws { + let field = field() + let root = FakeElement(role: "AXApplication", children: [field]) + + let result = try Act.run( + .type(.init(identifier: "sidebar.filter", text: "driving")), + in: root + ) + + #expect(field.performed == ["AXConfirm"]) + #expect(result.committed == true) + } + + /// A field that publishes every change as it happens needs nothing committing, + /// so this is reported rather than treated as a failure. + @Test("reports a field with no confirm action as uncommitted") + func reportsAnUncommittedWrite() throws { + let field = FakeElement( + role: "AXTextField", + identifier: "live", + settable: [kAXValueAttribute], + actions: [] + ) + let root = FakeElement(role: "AXApplication", children: [field]) + + let result = try Act.run(.type(.init(identifier: "live", text: "x")), in: root) + + #expect(result.confirmed == true) + #expect(result.committed == false) + #expect(field.performed.isEmpty) + } + + /// Text in the field that the application never saw is the worst outcome to + /// report as success, so a refused confirm fails the step. + @Test("fails when the edit cannot be committed") + func failsOnARefusedConfirm() throws { + let field = field() + field.performStatus = .cannotComplete + let root = FakeElement(role: "AXApplication", children: [field]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.type(.init(identifier: "sidebar.filter", text: "x")), in: root) + } + + #expect(error.kind == .actionFailed) + #expect(error.hint?.contains("not committed") == true) + } + + /// Typing replaces rather than appends, so a script does not have to clear the + /// field first and a second step cannot silently concatenate. + @Test("replaces what the field already held") + func replacesExistingText() throws { + let field = field() + field.attributes[kAXValueAttribute] = "old" + let root = FakeElement(role: "AXApplication", children: [field]) + + _ = try Act.run(.type(.init(identifier: "sidebar.filter", text: "new")), in: root) + + #expect(field.attributes[kAXValueAttribute] == "new") + } + + /// Clearing is typing nothing, not a step of its own. + @Test("an empty string clears the field") + func clearsTheField() throws { + let field = field() + field.attributes[kAXValueAttribute] = "something" + let root = FakeElement(role: "AXApplication", children: [field]) + + let result = try Act.run(.type(.init(identifier: "sidebar.filter", text: "")), in: root) + + #expect(field.attributes[kAXValueAttribute] == "") + #expect(result.confirmed == true) + } + + /// A static label and a disabled field both resolve by identifier and both + /// refuse the write. Failing here beats reporting a write that went nowhere. + @Test("fails on an element whose value is not writable") + func failsOnAReadOnlyElement() throws { + let label = FakeElement(role: "AXStaticText", identifier: "subtitle") + let root = FakeElement(role: "AXApplication", children: [label]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.type(.init(identifier: "subtitle", text: "x")), in: root) + } + + #expect(error.kind == .notEditable) + #expect(label.attributes[kAXValueAttribute] == nil) + } + + @Test("reports a refused write") + func reportsARefusedWrite() throws { + let field = field() + field.writeStatus = .cannotComplete + let root = FakeElement(role: "AXApplication", children: [field]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.type(.init(identifier: "sidebar.filter", text: "x")), in: root) + } + + #expect(error.kind == .writeFailed) + #expect(error.message.contains("cannot_complete")) + } + + /// The accessibility API lets a target accept a write and discard it, which is + /// the whole reason the step reads back instead of trusting the status. + @Test("reports an accepted write that did not take") + func reportsAnIneffectiveWrite() throws { + let field = field() + field.ignoresWrites = true + let root = FakeElement(role: "AXApplication", children: [field]) + + let result = try Act.run( + .type(.init(identifier: "sidebar.filter", text: "driving")), + in: root + ) + + #expect( + result.confirmed == false, + "a field that discarded the text must not report as confirmed" + ) + } + + @Test("reports an identifier it could not find") + func reportsAMissingIdentifier() throws { + let root = FakeElement(role: "AXApplication", children: [field()]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.type(.init(identifier: "nope", text: "x")), in: root) + } + + #expect(error.kind == .identifierNotFound) + } + + /// Only the addressed field is written to, so a step cannot quietly clobber a + /// second field that happens to sit nearby. + @Test("leaves other fields alone") + func leavesOtherFieldsAlone() throws { + let first = field(identifier: "one") + let second = field(identifier: "two") + let root = FakeElement(role: "AXApplication", children: [first, second]) + + _ = try Act.run(.type(.init(identifier: "two", text: "x")), in: root) + + #expect(first.attributes[kAXValueAttribute] == nil) + #expect(second.attributes[kAXValueAttribute] == "x") + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WaitForTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WaitForTests.swift new file mode 100644 index 000000000..7510632c2 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WaitForTests.swift @@ -0,0 +1,137 @@ +import Testing + +@testable import DriveKit + +@Suite("Act.waitFor") +struct WaitForTests { + /// A container that produces the awaited element on its third read, and not + /// before. + /// + /// The delay is what makes a wait test mean anything: against a tree that + /// already holds the element, polling and not polling look identical. + private func appearsOnThirdRead() -> FakeElement { + let container = FakeElement(role: "AXScrollArea", identifier: "transcript.scroll") + container.onRead = { element in + guard element.reads == 3 else { return } + element.children = [ + FakeElement(role: "AXGroup", identifier: "transcript.event.1") + ] + } + return container + } + + private func step( + _ identifier: String, + under: String? = nil, + timeoutMs: Int? = nil, + intervalMs: Int? = 1 + ) -> Step { + return .waitFor( + .init( + identifier: identifier, under: under, timeoutMs: timeoutMs, + intervalMs: intervalMs) + ) + } + + @Test("an element already present is returned on the first attempt") + func returnsImmediately() throws { + let root = FakeElement.sidebar(rowCount: 2) + + // A zero timeout permits exactly one attempt, so a pass here cannot have + // come from a retry. + let result = try Act.run(step("sidebar.row.1", timeoutMs: 0), in: root) + + #expect(result.step == "wait_for") + #expect(result.role == "AXUnknown") + #expect(result.confirmed == true) + } + + /// Half of a pair. This one proves the fixture genuinely withholds the element, + /// so that the passing case below is evidence of retrying rather than of the + /// element having been there all along. + @Test("one attempt is not enough for an element that appears later") + func oneAttemptIsNotEnough() throws { + let container = appearsOnThirdRead() + let root = FakeElement(role: "AXApplication", children: [container]) + + let error = try #require(throws: DriveError.self) { + try Act.run(step("transcript.event.1", timeoutMs: 0), in: root) + } + + #expect(error.kind == .timeout) + } + + @Test("polling finds an element that appears later") + func findsAnElementThatAppearsLater() throws { + let container = appearsOnThirdRead() + let root = FakeElement(role: "AXApplication", children: [container]) + + let result = try Act.run(step("transcript.event.1", timeoutMs: 2000), in: root) + + #expect(result.confirmed == true) + #expect(result.role == "AXGroup") + #expect( + container.reads >= 3, "the element cannot have been found before its third read") + } + + @Test("an element that never appears times out") + func timesOut() throws { + let root = FakeElement.sidebar(rowCount: 2) + + let error = try #require(throws: DriveError.self) { + try Act.run(step("never.appears", timeoutMs: 20), in: root) + } + + #expect(error.kind == .timeout) + #expect(error.message.contains("never.appears")) + } + + /// A single attempt eating the whole timeout is the failure mode that makes an + /// unscoped wait useless, so the error says what to do about it. + @Test("a timeout after one attempt suggests scoping") + func suggestsScopingAfterOneAttempt() throws { + let root = FakeElement.sidebar(rowCount: 2) + + let error = try #require(throws: DriveError.self) { + try Act.run(step("never.appears", timeoutMs: 0), in: root) + } + + #expect(error.hint?.contains("under") == true) + } + + /// Waiting inside something that does not exist is a mistake in the script, not + /// a condition that might come true. + @Test("a missing container fails at once rather than being waited for") + func missingContainerFailsFast() throws { + let root = FakeElement.sidebar(rowCount: 2) + + let error = try #require(throws: DriveError.self) { + try Act.run(step("anything", under: "no.such.container", timeoutMs: 5000), in: root) + } + + #expect(error.kind == .identifierNotFound) + } + + /// The reason `under` exists. Without it every attempt re-reads the whole + /// application, and against this app's sidebar one attempt outlasts a typical + /// timeout. + @Test("scoping keeps polling off the rest of the tree") + func scopingBoundsThePolling() throws { + let sidebar = FakeElement.sidebar(rowCount: 20) + let outline = try #require(sidebar.children.first) + let container = FakeElement(role: "AXScrollArea", identifier: "transcript.scroll") + let root = FakeElement(role: "AXApplication", children: [outline, container]) + + let error = try #require(throws: DriveError.self) { + try Act.run( + step("transcript.event.1", under: "transcript.scroll", timeoutMs: 30), + in: root + ) + } + #expect(error.kind == .timeout) + + // Read once while resolving the container, and never again. Repeated reads + // here would mean each poll was walking the sidebar. + #expect(outline.children.allSatisfy { $0.reads == 1 }) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowIDsTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowIDsTests.swift new file mode 100644 index 000000000..701110dc4 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowIDsTests.swift @@ -0,0 +1,129 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import DriveKit + +@Suite("WindowIDs") +struct WindowIDsTests { + /// One entry shaped the way the window server reports it: every number a + /// `CFNumber`, with the bounds arriving as doubles. + private func entry( + id: Int, + pid: Int, + layer: Int = 0, + title: String? = "JP", + width: Double = 1200, + height: Double = 800 + ) -> [String: Any] { + var window: [String: Any] = [ + kCGWindowNumber as String: id, + kCGWindowOwnerPID as String: pid, + kCGWindowLayer as String: layer, + kCGWindowBounds as String: ["X": 0.0, "Y": 0.0, "Width": width, "Height": height], + ] + window[kCGWindowName as String] = title + return window + } + + @Test("reports the window server's number and the window's size") + func reportsIdentifiers() { + let listed = [entry(id: 7412, pid: 4321)] + + #expect( + WindowIDs.capturable(from: listed, pid: 4321) == [ + CaptureWindow(id: 7412, title: "JP", width: 1200, height: 800) + ] + ) + } + + /// Every application on the desktop is in the list, so a capture that took the + /// first entry would photograph whatever happened to be frontmost. + @Test("keeps only the windows the pid owns") + func filtersByOwner() { + let listed = [ + entry(id: 1, pid: 999, title: "Terminal"), + entry(id: 2, pid: 4321), + entry(id: 3, pid: 111, title: "Finder"), + ] + + #expect(WindowIDs.capturable(from: listed, pid: 4321).map(\.id) == [2]) + } + + /// Tooltips, drag images and menu shadows are the app's too, and capturing one + /// in place of the window is a wrong answer rather than a failure. + @Test("keeps only windows on the normal layer") + func dropsChrome() { + let listed = [ + entry(id: 1, pid: 4321, layer: 25, title: "tooltip"), + entry(id: 2, pid: 4321), + ] + + #expect(WindowIDs.capturable(from: listed, pid: 4321).map(\.id) == [2]) + } + + /// A panel that has never been shown sits in the list at zero size, and + /// capturing it produces an empty file. + @Test("drops windows with no area") + func dropsEmptyWindows() { + let listed = [ + entry(id: 1, pid: 4321, width: 0, height: 0), + entry(id: 2, pid: 4321), + ] + + #expect(WindowIDs.capturable(from: listed, pid: 4321).map(\.id) == [2]) + } + + /// The window server withholds other applications' titles until the Screen + /// Recording grant is given, which is the state a first run is in. + @Test("a window with no readable title is still reported") + func toleratesAMissingTitle() { + let listed = [entry(id: 7412, pid: 4321, title: nil)] + + #expect( + WindowIDs.capturable(from: listed, pid: 4321) == [ + CaptureWindow(id: 7412, title: nil, width: 1200, height: 800) + ] + ) + } + + /// What actually arrives is a `CFArray` of `CFDictionary`, so every number in it + /// is an `NSNumber` once bridged, and a reader that only understood Swift's own + /// numeric types would report an application with no windows at all. + @Test("reads the numbers as the bridged types the window server hands over") + func readsBridgedNumbers() { + let listed: [[String: Any]] = [ + [ + kCGWindowNumber as String: NSNumber(value: 7412), + kCGWindowOwnerPID as String: NSNumber(value: 4321), + kCGWindowLayer as String: NSNumber(value: 0), + kCGWindowName as String: "JP", + kCGWindowBounds as String: [ + "X": NSNumber(value: 0.0), + "Y": NSNumber(value: 0.0), + "Width": NSNumber(value: 1200.0), + "Height": NSNumber(value: 800.0), + ], + ] + ] + + #expect( + WindowIDs.capturable(from: listed, pid: 4321) == [ + CaptureWindow(id: 7412, title: "JP", width: 1200, height: 800) + ] + ) + } + + /// Front-to-back is the window server's own order, and it is the only thing + /// telling a caller which of two windows to capture. + @Test("preserves the order the window server reported") + func preservesOrder() { + let listed = [ + entry(id: 3, pid: 4321), + entry(id: 1, pid: 4321), + entry(id: 2, pid: 4321), + ] + + #expect(WindowIDs.capturable(from: listed, pid: 4321).map(\.id) == [3, 1, 2]) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowsTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowsTests.swift new file mode 100644 index 000000000..437fa98e0 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowsTests.swift @@ -0,0 +1,61 @@ +import ApplicationServices +import Testing + +@testable import DriveKit + +@Suite("Windows") +struct WindowsTests { + @Test("reports each window's own facts") + func reportsWindowFacts() { + let main = FakeElement(role: "AXWindow", identifier: "workspace-AppWindow-1") + main.attributes[kAXTitleAttribute] = "JP" + main.attributes[kAXMainAttribute] = "1" + main.attributes[kAXMinimizedAttribute] = "0" + main.attributes["AXFrame"] = "0.0,0.0 1200.0x800.0" + + let other = FakeElement(role: "AXWindow", identifier: "workspace-AppWindow-2") + other.attributes[kAXMainAttribute] = "0" + other.attributes[kAXMinimizedAttribute] = "1" + + let app = FakeElement(role: "AXApplication") + app.related[kAXWindowsAttribute] = [main, other] + + #expect( + Windows.list(of: app) == [ + WindowSummary( + identifier: "workspace-AppWindow-1", + title: "JP", + main: true, + minimized: false, + frame: "0.0,0.0 1200.0x800.0" + ), + WindowSummary( + identifier: "workspace-AppWindow-2", + title: nil, + main: false, + minimized: true, + frame: nil + ), + ] + ) + } + + /// A running application with every window closed is a normal state, not an + /// error. + @Test("an application with no windows reports an empty list") + func noWindows() { + #expect(Windows.list(of: FakeElement(role: "AXApplication")).isEmpty) + } + + /// Windows are read from the application's own attribute, not found by walking + /// into the hierarchy. A listing that descended would pick up sheets and popups + /// as if they were windows. + @Test("windows are read from the attribute, not from the children") + func doesNotWalkChildren() { + let child = FakeElement(role: "AXWindow", identifier: "not.a.window") + let app = FakeElement(role: "AXApplication", children: [child]) + + #expect(Windows.list(of: app).isEmpty) + #expect(child.reads == 0) + } +} diff --git a/justfile b/justfile index 9c5e50bd0..93fbf41e5 100644 --- a/justfile +++ b/justfile @@ -181,6 +181,52 @@ build-ffi PROFILE="debug": (_install "cbindgen@" + cbindgen_version) echo "library: $out/libjp_ffi.a" >&2 echo "header: $out/include/jp_ffi.h" >&2 +# Build the `jpdrive` accessibility driver that the `debug_app_*` tools shell out +# to. +# +# A standalone SwiftPM package rather than a target in the app's Xcode project, +# so the binary lands at a predictable path with no derived-data lookup. +[group('build')] +[macos] +build-drive CONFIG="release": + #!/usr/bin/env sh + set -eu + + swift build --package-path apps/macos/Tools/jpdrive -c {{CONFIG}} + + bin=$(swift build --package-path apps/macos/Tools/jpdrive -c {{CONFIG}} --show-bin-path) + echo "binary: $bin/jpdrive" >&2 + +# Run the `jpdrive` test suite. +# +# Covers the driver's traversal against a fake accessibility tree, so it needs no +# running app and no accessibility grant. +[group('test')] +[macos] +test-drive *ARGS: + swift test --package-path apps/macos/Tools/jpdrive {{ARGS}} + +# Report whether this process may read another app's accessibility tree. +# +# Run under the terminal, under `just`, and under `serve-tools` to find out +# whether a TCC grant given to the terminal reaches a tool it started. See +# `apps/macos/Tools/jpdrive/README.md`. +# +# PID is the target application's process id, e.g. `$(pgrep -f JP.app)`. +[group('debug')] +[macos] +drive-doctor PID="": build-drive + #!/usr/bin/env sh + set -eu + + bin=$(swift build --package-path apps/macos/Tools/jpdrive -c release --show-bin-path) + + if [ -n "{{PID}}" ]; then + "$bin/jpdrive" doctor --pid "{{PID}}" + else + "$bin/jpdrive" doctor + fi + # Generate the macOS app's Xcode project from `apps/macos/project.yml`. # # The project file is generated rather than committed, so `project.yml` stays the From 47d900126199388e1a07182286a5c1fdaec76ac4 Mon Sep 17 00:00:00 2001 From: Jean Mertz <git@jeanmertz.com> Date: Wed, 19 Aug 2026 08:49:52 +0200 Subject: [PATCH 7/8] feat(macos): Add the UI test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app's unit tests run inside the app process, which puts three things out of reach: whether a menu item is enabled, what landed on the pasteboard, and what survives a terminate and relaunch. Those are exactly the behaviours that break without anyone noticing, and `QA.md` carried them as a manual checklist. This is the half of that checklist a machine can run. A UI test drives the app through its accessibility tree from a separate process, so `@testable import JP` is unavailable and must not be reached for. Anything checkable in-process belongs in `JPTests`, where it costs milliseconds instead of an app launch. Three things make the suite fast enough to be worth having. A suite shares one launched app, because launching costs about four seconds and the work under test costs milliseconds — so a test leaves the app as it found it, or the suite is ordered so that what one test leaves is what the next expects. Waits are on conditions rather than the clock, and not through `waitForExistence`, which reports an element about a second after it appears whatever the element; asking again finds it in under 100ms, and the timeout becomes the price of a failure rather than of a pass. And every animation goes through one lever the tests turn off, because XCUITest waits for the app to stop moving before each action it synthesizes. Anything the tests need the app to do differently goes through `DebugState`, gated on `#if DEBUG` and off unless an environment variable says otherwise, so a release build has no way to reach it. The pasteboard works this way and is enforced: no test may touch the system pasteboard, a debug build copies wherever `JP_DEBUG_PASTEBOARD` names, and `ClipboardPolicyTests` fails on any spelling of the general one. `just test-app-ui` is the CI job and runs everything even after a failure. While writing a test, run it by name instead. `fmt-app` and `lint-app` arrive here rather than with the app because they name every Swift directory in the repository, and this is the last one to exist. Signed-off-by: Jean Mertz <git@jeanmertz.com> --- apps/macos/UITests/AppUnderTest.swift | 566 ++++++++++++++++++ apps/macos/UITests/ConversationFixtures.swift | 117 ++++ .../macos/UITests/ConversationListTests.swift | 242 ++++++++ apps/macos/UITests/Diagnostics.swift | 69 +++ .../UITests/PinnedConversationTests.swift | 42 ++ apps/macos/UITests/PointerCursorTests.swift | 84 +++ apps/macos/UITests/Quiescence.swift | 96 +++ apps/macos/UITests/SharedApp.swift | 107 ++++ .../macos/UITests/TranscriptReflowTests.swift | 126 ++++ apps/macos/UITests/Transcripts.swift | 36 ++ apps/macos/UITests/UISuite.swift | 13 + apps/macos/UITests/WorkspaceFixture.swift | 273 +++++++++ apps/macos/project.yml | 23 + justfile | 30 + 14 files changed, 1824 insertions(+) create mode 100644 apps/macos/UITests/AppUnderTest.swift create mode 100644 apps/macos/UITests/ConversationFixtures.swift create mode 100644 apps/macos/UITests/ConversationListTests.swift create mode 100644 apps/macos/UITests/Diagnostics.swift create mode 100644 apps/macos/UITests/PinnedConversationTests.swift create mode 100644 apps/macos/UITests/PointerCursorTests.swift create mode 100644 apps/macos/UITests/Quiescence.swift create mode 100644 apps/macos/UITests/SharedApp.swift create mode 100644 apps/macos/UITests/TranscriptReflowTests.swift create mode 100644 apps/macos/UITests/Transcripts.swift create mode 100644 apps/macos/UITests/UISuite.swift create mode 100644 apps/macos/UITests/WorkspaceFixture.swift diff --git a/apps/macos/UITests/AppUnderTest.swift b/apps/macos/UITests/AppUnderTest.swift new file mode 100644 index 000000000..c884fcd80 --- /dev/null +++ b/apps/macos/UITests/AppUnderTest.swift @@ -0,0 +1,566 @@ +import Foundation +import Testing +import XCTest + +/// Stable names for the elements this suite reaches for. +/// +/// Deliberately spelled out rather than shared with the app's +/// `AccessibilityID`: these identifiers are the contract an external driver +/// holds the app to, documented in `AFFORDANCES.md`. A suite that imported the +/// constants would follow a rename instead of catching one, and a UI test runs +/// in another process anyway. +/// +/// `AccessibilityIDTests` pins the same strings from inside the app. +/// +/// Only the names this suite uses are here. The rest of the table stays out +/// until a test drives the state that shows it, so every name in this file is +/// one something depends on. +enum ID { + static let sidebarList = "sidebar.list" + static let sidebarFilter = "sidebar.filter" + static let sidebarFilterClear = "sidebar.filter.clear" + static let transcriptScroll = "transcript.scroll" + static let transcriptText = "transcript.text" + static let windowDivider = "window.divider" + + static func sidebarRow(_ conversationID: String) -> String { + "sidebar.row.\(conversationID)" + } + +} + +/// The app, launched against a fixture and driven from outside its process. +/// +/// Isolation is by environment, with one exception the environment cannot +/// reach: window state saved by `@SceneStorage` is keyed by bundle identifier, +/// and a UI test drives the developer's own build under the developer's own +/// identifier. `-ApplePersistenceIgnoreState` is the lever that leaves it +/// alone — the app neither restores what was saved nor saves what it had. +/// +/// A test of state restoration is the one case that needs the opposite, and +/// passes `keepingWindowState: true` knowingly. +/// +/// ## What a test costs +/// +/// A synthesized pointer event — a click, a double-click, a right-click, or +/// opening a menu-bar menu — costs 400-500ms. A key event costs ~50ms and +/// resolving an element ~40ms, so the pointer path is an order of magnitude +/// dearer than anything else a test does, and it dominates the run. +/// +/// None of it is the app. Timestamps on both sides put the app's own work 71ms +/// *after* `click()` has already returned, and the work itself at 2-4ms: XCTest +/// spends the 400ms before the event is delivered, so nothing the app does or +/// stops doing changes it. Turning off the post-event idle wait (see +/// ``Quiescence``) buys about 30ms of it and there is no second knob. +/// +/// What does move is the size of the accessibility tree, at roughly 0.3ms per +/// element per event. This fixture publishes ~230 elements, which is ~70ms of +/// each click; a fixture of 300 conversations publishes ~1130 and makes every +/// pointer event half again as expensive. Size a fixture for what the test +/// needs to say, not for realism. +/// +/// So: prefer a key event to a pointer event wherever the affordance allows, +/// and reach for a pointer event only where the pointer *is* what is under +/// test. +@MainActor +struct AppUnderTest { + let app: XCUIApplication + + /// How long to wait for the app to read its workspace and draw a list. + /// + /// Wider than ``timeout`` because it covers process start, not just work + /// the running app does. + static let launchTimeout: TimeInterval = 10 + + /// How long to wait for anything the app does once it is up. + /// + /// Deliberately short. Every wait here is on a condition rather than on the + /// clock, so a passing test returns the moment the element appears and this + /// number costs it nothing — it is the price of a *failure*, paid once per + /// broken assertion, and ten seconds of that is ten seconds of a red loop + /// spent watching a spinner. + /// + /// One second is far longer than anything the app does in reply to a click: + /// the workspace is already open by then, and reading a conversation of + /// four events is a file read. Raise it for a specific wait that genuinely + /// covers slower work rather than raising it here. + static let timeout: TimeInterval = 1 + + /// Launch against `fixture` and wait until the conversation list is on + /// screen. + /// + /// Waiting here rather than in each test is what keeps a test from acting on + /// a window that has not finished reading, which reads as an intermittent + /// failure rather than as the race it is. + static func launch( + against fixture: WorkspaceFixture, + keepingWindowState: Bool = false, + sourceLocation: SourceLocation = #_sourceLocation + ) -> AppUnderTest { + // Before the first event is synthesized, and reported rather than + // shrugged off: a suite quietly back to waiting after every event is a + // suite nobody notices has slowed down. + if let failure = Quiescence.installation { + let message = "the quiescence waits could not be turned off: \(failure)" + Diagnostics.append("\(sourceLocation.fileName):\(sourceLocation.line): \(message)") + Issue.record("\(message)", sourceLocation: sourceLocation) + } + + let app = XCUIApplication() + app.launchEnvironment = fixture.environment + if !keepingWindowState { + app.launchArguments = ["-ApplePersistenceIgnoreState", "YES"] + } + app.launch() + + let driven = AppUnderTest(app: app) + _ = driven.wait(for: driven.sidebar, timeout: launchTimeout) + + // Recorded once the app is up, so a run stopped part-way can still + // close it. Nothing else can: the app outlives the process that stops + // the run. See ``Diagnostics/processes``. + if let pid = fixture.appProcessID { + Diagnostics.recordAppProcess(pid) + } + + return driven + } + + /// Wait for `element` to exist, asking as often as asking costs. + /// + /// `XCUIElement.waitForExistence` reports an element about a second after + /// it appears, whatever it is: the two transcript waits in this suite + /// measured 1131ms and 1117ms against an app that draws them in tens of + /// milliseconds, and the number barely moves with the work involved. + /// + /// Resolving an element costs about 30ms, so a loop that simply asks again + /// polls at roughly 30Hz and finds it an order of magnitude sooner. No + /// sleep, and none needed: the query is what paces the loop. + func wait(for element: XCUIElement, timeout: TimeInterval = AppUnderTest.timeout) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + + repeat { + if element.exists { + return true + } + } while Date() < deadline + + return false + } + + func terminate() { + app.terminate() + } + + /// The window showing the workspace, addressed by the title it carries. + /// + /// By title rather than `windows.firstMatch`, because a test that opened a + /// conversation window leaves it behind for the next one and first is not + /// the same as the workspace's. + func workspaceWindow(_ fixture: WorkspaceFixture) -> XCUIElement { + app.windows.element(matching: NSPredicate(format: "title BEGINSWITH %@", fixture.name)) + } + + /// Close the window titled `title`, if it is open. + /// + /// Tests that open a window close it again, so the next one starts from the + /// same arrangement it would have found on its own. + /// + /// Command-W rather than the close button, because a synthesized pointer + /// event costs around 400ms and a key event around 50. It acts on whichever + /// window is in front, which is why the title is checked afterwards instead + /// of aimed at: a Command-W arriving while the workspace window was in front + /// would close *that*, and every test after it would fail somewhere far away + /// from the cause. + func closeWindow( + titled title: String, + sourceLocation: SourceLocation = #_sourceLocation + ) { + let window = app.windows[title] + guard window.exists else { return } + + app.typeKey("w", modifierFlags: .command) + + guard waitForDisappearance(of: window) else { + record( + """ + the window titled "\(title)" was still open after Command-W, so \ + the key window was something else and that is what closed. \ + On screen: \(capture("stuck window \(title)")) + """, + sourceLocation: sourceLocation + ) + return + } + } + + /// Wait for `element` to stop existing, asking as often as asking costs. + /// + /// The counterpart to ``wait(for:timeout:)``, and paced the same way. + func waitForDisappearance( + of element: XCUIElement, + timeout: TimeInterval = AppUnderTest.timeout + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + + repeat { + if !element.exists { + return true + } + } while Date() < deadline + + return false + } + + // Every accessor below names an element type and starts from the narrowest + // root it can. An untyped `descendants(matching: .any)` reads as convenient + // and costs a full snapshot of the app's accessibility tree on each + // evaluation, which is most of what a test spends its time on. The types + // are what the app actually publishes, read off a running instance with + // `debug_app_snapshot`. + + /// The conversation list, which exists only once the workspace is read. + /// + /// A SwiftUI `List` in a sidebar is an `NSOutlineView`. + var sidebar: XCUIElement { + app.outlines[ID.sidebarList] + } + + /// The box that narrows the conversation list. + var filter: XCUIElement { + app.textFields[ID.sidebarFilter] + } + + /// The button that empties the filter box, which exists only while the box + /// holds something. + var filterClear: XCUIElement { + app.buttons[ID.sidebarFilterClear] + } + + /// The scrolling transcript, which exists only once a conversation is read. + var transcript: XCUIElement { + app.scrollViews[ID.transcriptScroll] + } + + /// The row showing `conversation`. + /// + /// A row's identifier sits on the leaf inside its cell rather than on the + /// row, because the view carrying it collapses to one element. That leaf + /// reports no role of its own, which is why this asks for `.other` rather + /// than for a cell or a row. + func row(_ conversation: FixtureConversation) -> XCUIElement { + sidebar.descendants(matching: .other)[ID.sidebarRow(conversation.id)] + } + + /// The strip between the panes that resizes the sidebar. + /// + /// `.any` rather than a role, because the view reports none of its own: it is + /// a shape made into an accessibility element, and arrives as `AXUnknown`. + var divider: XCUIElement { + app.descendants(matching: .any)[ID.windowDivider] + } + + /// Wait until the system is displaying `cursor`. + /// + /// `NSCursor.currentSystem` reads what the window server is showing rather + /// than what this process asked for, so a test in another process can see the + /// cursor the app under test caused. Compared by image bytes: the accessor + /// hands back a fresh instance each time, so identity says nothing, and two + /// standard cursors differ in their pixels. + /// + /// Polled rather than read once, because the window server sets the cursor a + /// moment after the pointer arrives. + func waitForCursor( + _ cursor: NSCursor, + timeout: TimeInterval = AppUnderTest.timeout + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + + repeat { + if cursorIs(cursor) { + return true + } + } while Date() < deadline + + return false + } + + /// Whether the system is showing `cursor` right now. + /// + /// Compared by image bytes, and only ever used to ask about a cursor the test + /// is looking *for*. Not every standard cursor can be recognised this way — + /// `NSCursor.arrow.image` does not match the bytes the system reports while + /// showing the arrow — so a test that needs a baseline asks whether the + /// cursor is *not* the one it expects next, rather than trying to name what it + /// currently is. + func cursorIs(_ cursor: NSCursor) -> Bool { + guard let current = NSCursor.currentSystem?.image.tiffRepresentation else { + return false + } + + return current == cursor.image.tiffRepresentation + } + + /// The cursor the system is showing, named against the standard ones. + /// + /// For a failure message: an `NSCursor`'s own description is a pointer + /// address, which says only that it was not the expected one. + func describeCursor() -> String { + guard let current = NSCursor.currentSystem?.image.tiffRepresentation else { + return "a cursor the system would not report" + } + + let known: [(String, NSCursor)] = [ + ("the arrow", .arrow), + ("the I-beam", .iBeam), + ("the pointing hand", .pointingHand), + ("the open hand", .openHand), + ("the column-resize cursor", .columnResize), + ("the row-resize cursor", .rowResize), + ("the left-right resize cursor", .resizeLeftRight), + ] + + let match = known.first { $0.1.image.tiffRepresentation == current } + return match?.0 ?? "a cursor matching none of the standard ones" + } + + /// The text the transcript is drawn as. + /// + /// The whole conversation is one text view, so there is no element per + /// message. Its value is every message it is showing, which is how a test + /// asserts on what is on screen. + var transcriptText: XCUIElement { + app.textViews[ID.transcriptText] + } + + /// Wait until a transcript shows exactly `text`. + /// + /// Exactly, and against the whole document rather than a phrase inside it: the + /// value of the text view is every message it is showing, so a substring match + /// would survive the speaker labels going missing, the messages arriving in the + /// wrong order, or a second copy of the conversation being appended. + /// + /// `within` scopes the search to one window, which is how a conversation pulled + /// into its own window is told apart from the workspace window behind it. The + /// identifier is the same in both. + /// + /// Polled rather than read once, because a transcript arrives a moment after + /// the row is clicked. + @discardableResult + func expectTranscript( + _ text: String, + _ description: String, + within scope: XCUIElement? = nil, + timeout: TimeInterval = AppUnderTest.timeout, + sourceLocation: SourceLocation = #_sourceLocation + ) -> Bool { + let element = (scope ?? app).textViews[ID.transcriptText] + let deadline = Date().addingTimeInterval(timeout) + var last: String? + + repeat { + last = element.exists ? element.value as? String : nil + if last == text { + return true + } + } while Date() < deadline + + let shot = capture(description) + record( + """ + \(description) never showed the expected transcript within \(timeout)s. \ + Showing instead: \(last.map { "\($0.debugDescription)" } ?? "no transcript at all"). \ + On screen: tmp/uitests/\(shot) + """, + sourceLocation: sourceLocation + ) + return false + } + + /// Open the menu-bar menu titled `title` and return the menu it drops down, + /// so its items can be read. + /// + /// The dropped-down menu rather than the bar item, because it is the root + /// every item below is addressed from. A title is not unique across the + /// app: Copy Link is both an Edit-menu item and a context-menu item, and + /// AppKit publishes both to the accessibility tree whether or not either + /// menu is open, so a query starting at the app can return the wrong one. + /// Starting at the menu cannot. + /// + /// macOS populates a menu when it is opened, so an item's presence and + /// enablement still cannot be read from a closed one. + @discardableResult + func openMenu(_ title: String) -> XCUIElement { + let bar = app.menuBars.menuBarItems[title] + _ = wait(for: bar) + bar.click() + return bar.menus.firstMatch + } + + /// Close whatever menu is open, by pressing Escape. + func closeMenu() { + app.typeKey(.escape, modifierFlags: []) + } + + /// Open the menu-bar menu `menu` and click `item` in it. + func chooseMenuItem( + _ item: String, + in menu: String, + sourceLocation: SourceLocation = #_sourceLocation + ) { + let entry = openMenu(menu).menuItems[item] + guard + expectAppears(entry, "\(item) in the \(menu) menu", sourceLocation: sourceLocation) + else { return } + + entry.click() + } + + /// Click `item` in the context menu that is open. + /// + /// A context menu has no handle to start from the way a menu-bar menu does, + /// so this picks between same-titled items by hittability: only the items + /// of an open menu are hittable, and the context menu is the one that is + /// open. Getting it wrong is worth avoiding rather than merely detecting — + /// the menu-bar twin of a context item is usually disabled, so the click + /// lands and silently does nothing, which looks exactly like the app + /// ignoring the menu. + func chooseContextMenuItem( + _ item: String, + sourceLocation: SourceLocation = #_sourceLocation + ) { + let matches = app.menuItems.matching(identifier: item) + _ = wait(for: matches.firstMatch) + + guard let entry = matches.allElementsBoundByIndex.first(where: \.isHittable) else { + record( + """ + no open menu holds an item titled "\(item)": \ + \(matches.count) match it, none of them on screen. \ + On screen instead: \(capture(item)) + """, + sourceLocation: sourceLocation + ) + return + } + + entry.click() + } + + /// Whether `menu` holds an item titled `item`. + /// + /// Presence, not enablement: an item AppKit injects can be there and greyed + /// out — Merge All Windows is, until there is a second window to merge — + /// and it is the presence that says the menu was not rebuilt out from under + /// it. + func menuItemExists(_ item: String, in menu: XCUIElement) -> Bool { + menu.menuItems[item].exists + } + + /// Whether `menu`'s `item` can be chosen. + func menuItemIsEnabled(_ item: String, in menu: XCUIElement) -> Bool { + let entry = menu.menuItems[item] + return entry.exists && entry.isEnabled + } + + /// Wait for `element`, recording what was on screen instead when it never + /// arrives. + /// + /// A bare `#expect(element.exists)` reports only that something was + /// missing, which is the least useful half of the story: the app was + /// showing *something*, and what it was showing is usually the whole + /// answer. This writes that screen to a PNG and names the file in the + /// failure. + /// + /// The path rather than the image, because a tool result is text all the + /// way to the assistant reading it. Attach the file to say what it shows. + @discardableResult + func expectAppears( + _ element: XCUIElement, + _ description: String, + timeout: TimeInterval = AppUnderTest.timeout, + sourceLocation: SourceLocation = #_sourceLocation + ) -> Bool { + if wait(for: element, timeout: timeout) { + return true + } + + let shot = capture(description) + record( + "\(description) never appeared within \(timeout)s. On screen instead: tmp/uitests/\(shot)", + sourceLocation: sourceLocation + ) + return false + } + + /// Wait for `element` to go away, recording what is still on screen when it + /// does not. + /// + /// The counterpart to ``expectAppears(_:_:timeout:sourceLocation:)``, for + /// the assertions that say something was torn down rather than built. + @discardableResult + func expectDisappears( + _ element: XCUIElement, + _ description: String, + timeout: TimeInterval = AppUnderTest.timeout, + sourceLocation: SourceLocation = #_sourceLocation + ) -> Bool { + if waitForDisappearance(of: element, timeout: timeout) { + return true + } + + let shot = capture(description) + record( + "\(description) never went away within \(timeout)s. On screen: tmp/uitests/\(shot)", + sourceLocation: sourceLocation + ) + return false + } + + /// Record a failure, in both places a reader might look. + /// + /// `Issue.record` alone is not enough under `xcodebuild`, which prints the + /// header naming the *kind* of issue and drops the message explaining it: + /// a run of ten failures arrives as ten identical `Issue recorded` lines. + /// So the message also goes to a file `swift_test_ui` collects. That is + /// also what lets the tool stop the run: it watches for the failure, and + /// the message it reports afterwards comes from here rather than from + /// output that was cut off mid-write. + func record(_ message: String, sourceLocation: SourceLocation) { + Diagnostics.append("\(sourceLocation.fileName):\(sourceLocation.line): \(message)") + Issue.record("\(message)", sourceLocation: sourceLocation) + } + + /// Write what the app is showing to a PNG, and return its file name. + /// + /// The name rather than the path: the file is written into the runner's + /// container, and `swift_test_ui` copies it into `tmp/uitests/` under the + /// same name. Naming the container path here would give a reader a path + /// that is longer and gone by the next run. + /// + /// Returns why it could not be written rather than throwing, because this + /// runs while a test is already failing and a second failure would bury the + /// first. + func capture(_ description: String) -> String { + let name = + description + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: " ", with: "-") + .prefix(80) + let file = Diagnostics.directory + .appendingPathComponent("\(name)-\(UUID().uuidString.prefix(8)).png") + + do { + try FileManager.default.createDirectory( + at: Diagnostics.directory, + withIntermediateDirectories: true + ) + try app.screenshot().pngRepresentation.write(to: file) + } catch { + return "(no screenshot: \(error))" + } + + return file.lastPathComponent + } + +} diff --git a/apps/macos/UITests/ConversationFixtures.swift b/apps/macos/UITests/ConversationFixtures.swift new file mode 100644 index 000000000..3dafd9806 --- /dev/null +++ b/apps/macos/UITests/ConversationFixtures.swift @@ -0,0 +1,117 @@ +/// The workspace the conversation-list tests run against. +/// +/// A type of its own rather than statics on the suite, because a suite's own +/// `.sharedApp(...)` attribute cannot name the suite it is attached to: the +/// macro would have to resolve the type it is in the middle of expanding. +enum ConversationFixtures { + /// Oldest activity, so it sorts last. + static let readingList = FixtureConversation( + id: "17251488000", + title: "Reading list", + lastActivatedAt: "2024-09-01 09:00:00.0", + events: [ + FixtureConversation.userMessage( + at: "2024-09-01 09:00:00.0", from: "Jean", "What is on the reading list?"), + FixtureConversation.assistantMessage( + at: "2024-09-01 09:00:01.0", "Three books and a paper."), + ] + ) + + static let configPipeline = FixtureConversation( + id: "17251488010", + title: "Config pipeline", + lastActivatedAt: "2024-09-02 09:00:00.0", + events: [ + FixtureConversation.userMessage( + at: "2024-09-02 09:00:00.0", from: "Jean", "How does the config pipeline layer?" + ), + FixtureConversation.assistantMessage( + at: "2024-09-02 09:00:01.0", "Later layers win, field by field."), + ] + ) + + /// Newest activity, so it sorts first. + static let releaseNotes = FixtureConversation( + id: "17251488020", + title: "Release notes", + lastActivatedAt: "2024-09-03 09:00:00.0", + events: [ + FixtureConversation.userMessage( + at: "2024-09-03 09:00:00.0", from: "Jean", "Draft the release notes."), + FixtureConversation.assistantMessage( + at: "2024-09-03 09:00:01.0", "Drafted, with one open question."), + FixtureConversation.userMessage( + at: "2024-09-03 09:00:02.0", from: "Jean", "Answer it yourself."), + FixtureConversation.assistantMessage( + at: "2024-09-03 09:00:03.0", "Answered."), + ] + ) + + /// ``readingList``, pinned. + /// + /// The oldest of the three, so a list showing it first can only be showing it + /// there because it is pinned. + static let pinnedReadingList = FixtureConversation( + id: readingList.id, + title: readingList.title, + lastActivatedAt: readingList.lastActivatedAt, + pinnedAt: "2024-09-04 09:00:00.0", + events: readingList.events + ) + + /// One conversation tall enough to scroll, for the tests about re-wrapping. + /// + /// Prose rather than a repeated line, because the thing under test is text + /// finding new line breaks at a new width: a paragraph of one word repeated + /// wraps at the same places whatever the width, and would reflow invisibly. + static let longRead = FixtureConversation( + id: "17251488030", + title: "Long read", + lastActivatedAt: "2024-09-05 09:00:00.0", + events: [ + FixtureConversation.userMessage( + at: "2024-09-05 09:00:00.0", from: "Jean", "Explain the layout pipeline."), + FixtureConversation.assistantMessage( + at: "2024-09-05 09:00:01.0", paragraphs(40)), + ] + ) + + /// `count` paragraphs of varied prose, as one markdown message. + /// + /// Numbered so a reader of a failure can tell where in the document they are, + /// and of uneven length so the line breaks are not all in the same column. + private static func paragraphs(_ count: Int) -> String { + (1...count) + .map { index in + """ + ## Section \(index) + + The layout pipeline measures what it is given and wraps it to the \ + width it is offered, which is why a window resize is a text \ + problem rather than a drawing one. Paragraph \(index) exists to \ + take up enough room that the document is taller than any window \ + showing it. + """ + } + .joined(separator: "\n\n") + } + + /// A workspace holding all three. + static func make() throws -> WorkspaceFixture { + try WorkspaceFixture.make(conversations: [readingList, configPipeline, releaseNotes]) + } + + /// A workspace holding only ``longRead``. + /// + /// One conversation, so the sidebar publishes almost nothing and every + /// synthesized event in the test is cheap. + static func makeLongRead() throws -> WorkspaceFixture { + try WorkspaceFixture.make(conversations: [longRead]) + } + + /// The same three, with the oldest one pinned. + static func makeWithPinnedOldest() throws -> WorkspaceFixture { + try WorkspaceFixture.make( + conversations: [pinnedReadingList, configPipeline, releaseNotes]) + } +} diff --git a/apps/macos/UITests/ConversationListTests.swift b/apps/macos/UITests/ConversationListTests.swift new file mode 100644 index 000000000..8bc1908e5 --- /dev/null +++ b/apps/macos/UITests/ConversationListTests.swift @@ -0,0 +1,242 @@ +import Testing +import XCTest + +/// The Conversation list section of `QA.md`, run rather than read. +/// +/// The workspace is three conversations with fixed IDs, titles and activity +/// times, so a test can name the row it wants and say where it should sit. +/// +/// One app for the whole suite. These tests read the list and move the +/// selection around, which is state the next test can set for itself, so paying +/// a launch and a terminate each to start from a fresh process buys nothing. A +/// test that needs an app nobody has touched says so and launches its own with +/// ``AppUnderTest/launch(against:keepingWindowState:sourceLocation:)``. +extension UISuite { + @Suite( + "ConversationList", + .sharedApp { try ConversationFixtures.make() } + ) + @MainActor + struct ConversationListTests { + /// The suite's app, and the workspace it was launched against. + var driven: AppUnderTest { SharedAppBox.shared.app } + var fixture: WorkspaceFixture { SharedAppBox.shared.workspace } + + /// The workspace's directory name and nothing else. The window carries a + /// title because the Window menu lists it and a driver addresses it by + /// it, but it says only which workspace the window is on: a subtitle + /// counting conversations put a strip of chrome above the transcript that + /// the design does not have. + @Test("titles the window with the workspace name alone") + func namesTheWorkspace() { + #expect(driven.workspaceWindow(fixture).title == fixture.name) + } + + @Test("orders conversations most recently active first") + func ordersByActivity() { + let newest = driven.row(ConversationFixtures.releaseNotes) + let middle = driven.row(ConversationFixtures.configPipeline) + let oldest = driven.row(ConversationFixtures.readingList) + + guard + driven.expectAppears(newest, "the Release notes row"), + driven.expectAppears(middle, "the Config pipeline row"), + driven.expectAppears(oldest, "the Reading list row") + else { return } + + #expect(newest.frame.minY < middle.frame.minY) + #expect(middle.frame.minY < oldest.frame.minY) + } + + /// The date the row also shows is deliberately absent from its label: it + /// is relative for anything active today, so an assertion on it would + /// pass or fail depending on the minute the suite ran. + @Test("shows a row's title and event count together") + func labelsRows() { + #expect( + driven.row(ConversationFixtures.releaseNotes).label + == "Release notes, \(ConversationFixtures.releaseNotes.eventCountLabel)" + ) + } + + /// The row going away and coming back is what says the binding behind the + /// field is live, rather than the field merely showing the letters typed + /// into it: an accessibility value can be set on a text field without ever + /// reaching the state the list is drawn from. + /// + /// Leaves the box empty again, because the suite shares one app and every + /// test after this one expects the whole list. + @Test("narrows the list while filtering, and restores it when cleared") + func filtersAndClears() { + let hidden = driven.row(ConversationFixtures.readingList) + guard + driven.expectAppears(hidden, "the Reading list row"), + // Present whether or not there is anything to clear, so it is + // there to be found before a word has been typed. + driven.expectAppears(driven.filterClear, "the clear button") + else { return } + + driven.filter.click() + driven.filter.typeText("Release") + + guard driven.expectDisappears(hidden, "the Reading list row, once filtered") + else { return } + + driven.filterClear.click() + + driven.expectAppears(hidden, "the Reading list row, once cleared") + } + + /// The whole transcript, exactly: two messages, each under the name of + /// whoever said it. + @Test("selects a row on click, and the transcript follows") + func clickSelects() { + driven.row(ConversationFixtures.configPipeline).click() + + driven.expectTranscript( + Transcripts.configPipeline, "the Config pipeline transcript") + } + + /// The whole row is the click target, not just the text in it. A row + /// built as a label with padding around it leaves the padding dead, and + /// clicking beside a title is what a person does. + @Test("selects a row clicked in the empty space beside its title") + func clickBesideTitleSelects() { + driven.row(ConversationFixtures.readingList) + .coordinate(withNormalizedOffset: CGVector(dx: 0.75, dy: 0.85)) + .click() + + driven.expectTranscript(Transcripts.readingList, "the Reading list transcript") + } + + @Test("moves the selection with the arrow keys") + func arrowKeysMoveSelection() { + // Start at the top row, so one press down lands on a known one. + driven.row(ConversationFixtures.releaseNotes).click() + guard + driven.expectTranscript( + Transcripts.releaseNotes, "the Release notes transcript") + else { return } + + driven.app.typeKey(.downArrow, modifierFlags: []) + + driven.expectTranscript( + Transcripts.configPipeline, + "the Config pipeline transcript, after pressing down" + ) + } + + @Test("opens a conversation in its own window on double-click") + func doubleClickOpensAWindow() { + driven.row(ConversationFixtures.readingList).doubleClick() + defer { driven.closeWindow(titled: "Reading list") } + + let opened = driven.app.windows["Reading list"] + guard driven.expectAppears(opened, "a window titled Reading list") else { return } + + // Showing the conversation, not an empty pane. + driven.expectTranscript( + Transcripts.readingList, + "the conversation inside its own window", + within: opened + ) + } + + /// A different conversation from the double-click test, so a window that + /// test failed to close could not make this one pass. + @Test("opens a conversation in its own window from the context menu") + func contextMenuOpensAWindow() { + driven.row(ConversationFixtures.releaseNotes).rightClick() + driven.chooseContextMenuItem("Open in New Window") + defer { driven.closeWindow(titled: "Release notes") } + + let opened = driven.app.windows["Release notes"] + guard driven.expectAppears(opened, "a window titled Release notes") else { return } + + driven.expectTranscript( + Transcripts.releaseNotes, + "the conversation inside its own window", + within: opened + ) + } + + /// The URI lands on a pasteboard of the fixture's own, never the system + /// one — the app under test is told which to use, and + /// `ClipboardPolicyTests` holds the suite to it. + @Test("copies a conversation's URI from the context menu") + func contextMenuCopiesTheURI() { + driven.row(ConversationFixtures.readingList).rightClick() + driven.chooseContextMenuItem("Copy Link") + + #expect(fixture.copiedText() == ConversationFixtures.readingList.uri) + } + + /// Edit ▸ Copy Link acts on the sidebar selection, so it is greyed out + /// until there is one — and Escape is how a window gets back to having + /// none. + /// + /// The empty pane is waited for rather than assumed. Without it the + /// disabled half of this test would also pass against an Escape that did + /// nothing, in a suite where every earlier test leaves a selection + /// behind. + @Test("enables Edit ▸ Copy Link only once a conversation is selected") + func editCopyLinkFollowsTheSelection() { + driven.row(ConversationFixtures.configPipeline).click() + guard + driven.expectTranscript( + Transcripts.configPipeline, "the Config pipeline transcript") + else { return } + + driven.app.typeKey(.escape, modifierFlags: []) + guard + driven.expectDisappears(driven.transcript, "the transcript, after Escape") + else { return } + + let edit = driven.openMenu("Edit") + #expect(driven.menuItemIsEnabled("Copy Link", in: edit) == false) + driven.closeMenu() + + driven.row(ConversationFixtures.configPipeline).click() + driven.chooseMenuItem("Copy Link", in: "Edit") + + #expect(fixture.copiedText() == ConversationFixtures.configPipeline.uri) + } + + /// The window holds its two panes itself rather than in a + /// `NavigationSplitView`, so this item is the app's own and not AppKit's. + /// Its title flips with what it will do, and it is the only way back to a + /// hidden sidebar — there is no button for it. + /// + /// Leaves the sidebar showing, because the suite shares one app and every + /// other test addresses a row. + @Test("hides and shows the sidebar from the View menu") + func viewMenuTogglesTheSidebar() { + guard driven.expectAppears(driven.sidebar, "the conversation list") else { return } + + driven.chooseMenuItem("Hide Sidebar", in: "View") + guard + driven.expectDisappears(driven.sidebar, "the conversation list, once hidden") + else { return } + + driven.chooseMenuItem("Show Sidebar", in: "View") + driven.expectAppears(driven.sidebar, "the conversation list, brought back") + } + + /// Enter Full Screen and Merge All Windows are items AppKit injects into + /// menus SwiftUI builds from its own commands. A menu bar rebuilt at the + /// wrong moment — which a focused value that never compares equal to + /// itself causes, on every render — drops them. + @Test("keeps the AppKit-injected View and Window items after selecting") + func selectingKeepsTheInjectedMenuItems() { + driven.row(ConversationFixtures.releaseNotes).click() + + let view = driven.openMenu("View") + #expect(driven.menuItemExists("Enter Full Screen", in: view)) + driven.closeMenu() + + let window = driven.openMenu("Window") + #expect(driven.menuItemExists("Merge All Windows", in: window)) + driven.closeMenu() + } + } +} diff --git a/apps/macos/UITests/Diagnostics.swift b/apps/macos/UITests/Diagnostics.swift new file mode 100644 index 000000000..cc5fd8729 --- /dev/null +++ b/apps/macos/UITests/Diagnostics.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Where a UI test writes what a reader needs and `xcodebuild` will not carry. +/// +/// Two things end up here: screenshots of what was on screen when an assertion +/// failed, and the failure messages themselves. The messages need a home +/// because swift-testing prints an issue's text on a line of its own, under a +/// header naming only the kind of issue, and `xcodebuild` keeps the header and +/// drops the line — so ten failures arrive as ten identical `Issue recorded` +/// entries, which says how many things broke and nothing about what. +/// +/// The directory is the runner's container, not the checkout. Xcode wraps a UI +/// test bundle in a generated, sandboxed runner app, so a write anywhere in the +/// project fails with `Operation not permitted` however the path is spelled. +/// `swift_test_ui` copies out of here and into `tmp/uitests/`. +enum Diagnostics { + /// The directory both screenshots and messages are written to. + static let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("jp-uitests") + + /// Where the messages are written. + static let file = directory.appendingPathComponent("failures.txt") + + /// Where the process ids of the apps this run launched are written. + /// + /// A run stopped part-way is stopped from outside, by killing + /// `xcodebuild`. That does not reach the app: it is `testmanagerd` that + /// launched it, so it survives and stays on screen. These are how the tool + /// that stopped the run finds it, exactly, without matching on a name the + /// developer's own copy of JP also has. + static let processes = directory.appendingPathComponent("app.pids") + + /// Note that an app was launched, so a stopped run can still close it. + static func recordAppProcess(_ pid: String) { + append(pid, to: processes) + } + + /// Append one line, creating the file if this is the first. + /// + /// Silent on failure. This runs while a test is already failing, and a + /// second failure would bury the first. + static func append(_ line: String) { + append(line, to: file) + } + + private static func append(_ line: String, to file: URL) { + guard let data = (line + "\n").data(using: .utf8) else { return } + + try? FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + + guard let handle = try? FileHandle(forWritingTo: file) else { + try? data.write(to: file) + return + } + + defer { try? handle.close() } + + do { + try handle.seekToEnd() + try handle.write(contentsOf: data) + } catch { + // Nothing useful left to do: the test is already failing, and the + // message is on its way to `Issue.record` regardless. + } + } +} diff --git a/apps/macos/UITests/PinnedConversationTests.swift b/apps/macos/UITests/PinnedConversationTests.swift new file mode 100644 index 000000000..e52aed472 --- /dev/null +++ b/apps/macos/UITests/PinnedConversationTests.swift @@ -0,0 +1,42 @@ +import Testing +import XCTest + +/// Pinning, end to end: a `pinned_at` timestamp on disk, through the library and +/// its C ABI, to a row that sits at the top of the list and says so. +/// +/// Its own app and its own workspace, unlike the rest of the list tests. The +/// shared fixture has no pins, and pinning one of its three conversations would +/// move the row that every ordering assertion in `ConversationListTests` names. +extension UISuite { + @Suite("PinnedConversations") + @MainActor + struct PinnedConversationTests { + @Test("lifts a pinned conversation above a more recently active one") + func pinnedSortsFirst() throws { + let fixture = try ConversationFixtures.makeWithPinnedOldest() + defer { fixture.remove() } + + let driven = AppUnderTest.launch(against: fixture) + defer { driven.terminate() } + + let pinned = driven.row(ConversationFixtures.pinnedReadingList) + let newest = driven.row(ConversationFixtures.releaseNotes) + + guard + driven.expectAppears(pinned, "the pinned Reading list row"), + driven.expectAppears(newest, "the Release notes row") + else { return } + + // Reading list is the oldest of the three, so without the pin it sits + // below both others; this is the pin moving it and nothing else. + #expect(pinned.frame.minY < newest.frame.minY) + + // And the row says it is pinned, which is the only way anything + // outside the app can tell the pin glyph is drawn. + #expect( + pinned.label + == "Reading list, \(ConversationFixtures.pinnedReadingList.eventCountLabel), pinned" + ) + } + } +} diff --git a/apps/macos/UITests/PointerCursorTests.swift b/apps/macos/UITests/PointerCursorTests.swift new file mode 100644 index 000000000..498e12c1c --- /dev/null +++ b/apps/macos/UITests/PointerCursorTests.swift @@ -0,0 +1,84 @@ +import AppKit +import Foundation +import Testing +import XCTest + +/// What the pointer becomes over the things that respond to it. +/// +/// The only test in this project that can see a cursor. A cursor is not in the +/// accessibility tree and is not composited into a screenshot, so nothing inside +/// the app can prove one was delivered — `ResizeCursorAreaTests` asserts the view +/// *asks* for a cursor and stayed green through two states where the pointer +/// never changed, which is exactly the gap this closes. +/// +/// `NSCursor.currentSystem` reads what the window server is displaying rather +/// than what the calling process requested, so this test process can read the +/// cursor the app under test caused. +/// +/// Its own app: it moves the pointer around and leaves it wherever the last hover +/// put it, which is not a state to hand the next suite. +extension UISuite { + @Suite("PointerCursor") + @MainActor + struct PointerCursorTests { + /// The pointer becomes the horizontal-resize cursor over the strip that + /// resizes the sidebar. + /// + /// Dragging that strip works, and the view asks for the right cursor over + /// the right area, and the pointer still does not change — the request is + /// made and not delivered. Until this passes, that is unfixed. + @Test("shows the horizontal-resize cursor over the pane divider") + func showsResizeCursorOverTheDivider() { + let fixture = try? ConversationFixtures.make() + guard let fixture else { + Issue.record("could not build the fixture workspace") + return + } + defer { fixture.remove() } + + let driven = AppUnderTest.launch(against: fixture) + defer { driven.terminate() } + + guard driven.expectAppears(driven.divider, "the pane divider") else { return } + + // The baseline, and it is not optional. `NSCursor.currentSystem` reads + // the cursor for the whole machine, so a column-resize cursor left + // showing by anything at all would pass the assertion below without + // this app having done a thing. Establishing the arrow first turns "it + // is the right cursor" into "it changed to the right cursor". + // + // The conversation list rather than the transcript: the transcript does + // not exist until something is selected, and hovering a missing element + // fails without stopping the test — which is how an earlier version of + // this passed with no baseline at all. + driven.sidebar.hover() + let arrowFirst = driven.waitForCursor(.arrow) + + #expect( + arrowFirst, + """ + over the conversation list the pointer was \(driven.describeCursor()) \ + rather than the arrow, so this run cannot say whether the divider \ + changed anything. + """ + ) + guard arrowFirst else { return } + + driven.divider.hover() + + // Read into a `Bool` first: swift-testing reports the expression it + // evaluated, and `driven` holds an `XCUIApplication` whose description + // is the entire element tree. + let changed = driven.waitForCursor(.columnResize) + + #expect( + changed, + """ + the pointer over the pane divider did not become the column-resize \ + cursor. It stayed \(driven.describeCursor()). The strip drags \ + correctly, so the gesture reaches it and the pointer does not. + """ + ) + } + } +} diff --git a/apps/macos/UITests/Quiescence.swift b/apps/macos/UITests/Quiescence.swift new file mode 100644 index 000000000..d864cefc3 --- /dev/null +++ b/apps/macos/UITests/Quiescence.swift @@ -0,0 +1,96 @@ +import Foundation +import ObjectiveC + +/// Stops XCUITest waiting for the app under test to go quiet after every event +/// it synthesizes. +/// +/// Worth about a second of a sixteen-second run, which is less than it sounds +/// like it should be: the wait is not what makes a synthesized click expensive. +/// A click costs ~410ms with this installed and ~440ms without, against an app +/// that answers in tens of milliseconds; the rest is inside XCTest's pointer +/// path and out of reach from here. Do not expect a second one of these to turn +/// up. +/// +/// Safe because nothing in this suite leans on the wait. Every assertion waits +/// on a condition of its own through ``AppUnderTest/wait(for:)``, which is +/// faster and specific about what it is waiting for; an implicit settle after +/// each event only hides where a real one is missing. A test that starts +/// failing after a change here is a test that was relying on it — give it the +/// wait it actually needs rather than putting this one back. +/// +/// Private API, reached by replacing two method implementations. It lives in +/// the test bundle and nothing ships it. It is version-fragile: the selector +/// this replaces was one argument in 2016, is two now, and picked up a third in +/// a variant along the way. So ``install()`` checks every assumption it makes +/// and reports rather than guessing, and ``AppUnderTest/launch(against:)`` +/// fails the run when it reports. A silent no-op would put the second back and +/// tell nobody. +enum Quiescence { + /// What went wrong installing this, or `nil` if it took. + /// + /// A `let`, so the work happens once however many apps a run launches. + static let installation: String? = install() + + /// The class that does the waiting. + private static let className = "XCUIApplicationProcess" + + /// Replace both waits, or say why not. + /// + /// Both, not either: XCTest calls the plain one and the one that opens an + /// activity around the wait, and leaving one in place leaves its share of + /// the cost in place with it. + /// + /// `shouldSkipPreEventQuiescence` and `shouldSkipPostEventQuiescence` look + /// like the better target — no arguments, `BOOL` return, nothing to get + /// wrong — and forcing both to `true` measurably changes nothing. XCTest + /// does not consult them on the path that costs. + /// + /// The encodings are checked rather than assumed, because a replacement is + /// called through a signature the runtime does not police: a method that + /// gained an argument, or that returns something other than `void`, would + /// be called with the wrong frame and go wrong somewhere unrelated. `v` is + /// void, `@0:8` the receiver and selector every method takes, and each `B` + /// a `_Bool` argument. `B` rather than `c` also pins this to a machine + /// where `BOOL` is `_Bool` — the Swift `Bool` the blocks below are written + /// with matches that and not the `signed char` an Intel Mac would want. + private static func install() -> String? { + guard let process: AnyClass = NSClassFromString(className) else { + return "XCTest no longer has a class named \(className)." + } + + let two: @convention(block) (AnyObject, Bool, Bool) -> Void = { _, _, _ in } + let three: @convention(block) (AnyObject, Bool, Bool, Bool) -> Void = { _, _, _, _ in } + + let replacements = [ + ( + name: "waitForQuiescenceIncludingAnimationsIdle:isPreEvent:", + encoding: "v24@0:8B16B20", + imp: imp_implementationWithBlock(two) + ), + ( + name: "waitForQuiescenceIncludingAnimationsIdle:usingActivity:isPreEvent:", + encoding: "v28@0:8B16B20B24", + imp: imp_implementationWithBlock(three) + ), + ] + + for replacement in replacements { + let selector = NSSelectorFromString(replacement.name) + guard let method = class_getInstanceMethod(process, selector) else { + return "\(className) no longer answers \(replacement.name)." + } + + let found = method_getTypeEncoding(method).map { String(cString: $0) } ?? "(none)" + guard found == replacement.encoding else { + return """ + \(className).\(replacement.name) is \(found), \ + expected \(replacement.encoding). + """ + } + + method_setImplementation(method, replacement.imp) + } + + return nil + } +} diff --git a/apps/macos/UITests/SharedApp.swift b/apps/macos/UITests/SharedApp.swift new file mode 100644 index 000000000..2b357be73 --- /dev/null +++ b/apps/macos/UITests/SharedApp.swift @@ -0,0 +1,107 @@ +import Testing +import XCTest + +/// Runs a suite's tests against one launched app instead of one each. +/// +/// Launching costs seconds and the work under test costs milliseconds, so a +/// suite that launches per test spends almost all of its time starting and +/// stopping the app. This launches once, hands the same instance to every test +/// in the suite, and terminates it when the suite finishes. +/// +/// The trade is that tests share what the app remembers. A suite using this has +/// to leave the app as it found it, or order its tests so that what one leaves +/// behind is what the next one expects. A test that cannot work that way asks +/// for its own instance with ``AppUnderTest/launch(against:)`` and terminates +/// it itself. +/// +/// Safe despite the shared mutable state because ``UISuite`` is serialized: +/// only one test runs at a time, and all of this is main-actor isolated. +struct SharedApp: SuiteTrait, TestScoping { + /// The fixture the app is launched against. + let fixture: @Sendable () throws -> WorkspaceFixture + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: () async throws -> Void + ) async throws { + let fixture = try fixture() + let driven = await AppUnderTest.launch(against: fixture) + await SharedAppBox.shared.set(driven, fixture: fixture) + + // Torn down on both paths rather than in a `defer`, so terminating is + // awaited: a `defer` would have to spawn a task to reach the main actor, + // and a fire-and-forget task can lose the race with the process exiting + // — leaving the app on the developer's screen. + do { + try await function() + } catch { + await Self.teardown(driven, fixture) + throw error + } + + await Self.teardown(driven, fixture) + } + + @MainActor + private static func teardown(_ driven: AppUnderTest, _ fixture: WorkspaceFixture) { + SharedAppBox.shared.clear() + driven.terminate() + fixture.remove() + } +} + +extension Trait where Self == SharedApp { + /// One app for the whole suite, launched against `fixture`. + static func sharedApp( + _ fixture: @escaping @Sendable () throws -> WorkspaceFixture + ) -> Self { + SharedApp(fixture: fixture) + } +} + +/// Where the suite's app is kept between the trait that launches it and the +/// tests that use it. +/// +/// A global rather than a property on the suite, because swift-testing builds a +/// fresh suite value for every test: anything stored on the suite is gone by +/// the time the next test runs. +@MainActor +final class SharedAppBox { + static let shared = SharedAppBox() + + private var driven: AppUnderTest? + private var fixture: WorkspaceFixture? + + private init() {} + + func set(_ driven: AppUnderTest, fixture: WorkspaceFixture) { + self.driven = driven + self.fixture = fixture + } + + func clear() { + driven = nil + fixture = nil + } + + /// The app the suite is running against. + /// + /// Traps rather than returning an optional every caller has to unwrap: a + /// test reaching for this without ``SharedApp`` on its suite is a mistake in + /// the test, and every assertion after it would be meaningless anyway. + var app: AppUnderTest { + guard let driven else { + fatalError("no shared app: put `.sharedApp(...)` on the suite") + } + return driven + } + + /// The workspace the suite's app was launched against. + var workspace: WorkspaceFixture { + guard let fixture else { + fatalError("no shared app: put `.sharedApp(...)` on the suite") + } + return fixture + } +} diff --git a/apps/macos/UITests/TranscriptReflowTests.swift b/apps/macos/UITests/TranscriptReflowTests.swift new file mode 100644 index 000000000..8aaae0069 --- /dev/null +++ b/apps/macos/UITests/TranscriptReflowTests.swift @@ -0,0 +1,126 @@ +import Foundation +import Testing +import XCTest + +/// Whether the transcript re-wraps while a window is being dragged. +/// +/// Its own app rather than the shared one: it needs a conversation tall enough +/// to scroll, and it resizes and scrolls the window it is given, which is not a +/// state to hand the next suite. +extension UISuite { + @Suite("TranscriptReflow") + @MainActor + struct TranscriptReflowTests { + /// The interval the app writes once per window drag. + private static let drag = "transcript.liveresize" + + /// How far the drag moves the window's right edge, in points. + /// + /// Outwards. A window opened fresh sits at its minimum width, because the + /// scene names no default size and SwiftUI takes the smallest its content + /// allows — so a drag inwards has nowhere to go, moves the pointer, resizes + /// nothing, and delivers no frames at all. + private static let dragBy: CGFloat = 220 + + /// The whole point: text re-wraps on every frame of a window drag, not + /// once the mouse comes up. + /// + /// A window resize reaches the text through the text container, whose width + /// the text view is supposed to keep in step with its own. AppKit does not + /// do that while a resize is in progress, so nothing changes the + /// container's geometry, nothing invalidates layout, and the view redraws + /// lines wrapped to a width the window no longer has. The app sets the + /// container's width itself for exactly this reason. + /// + /// Asserted through the app's own trace rather than off the screen, because + /// the defect leaves nothing behind: on mouse-up the container catches up + /// and the text is correct either way. Only what happened *during* the drag + /// tells the two apart. + @Test("re-wraps the transcript during a window drag, scrolled away from the top") + func reflowsWhileDragging() throws { + let fixture = try ConversationFixtures.makeLongRead() + defer { fixture.remove() } + + let driven = AppUnderTest.launch(against: fixture) + defer { driven.terminate() } + + driven.row(ConversationFixtures.longRead).click() + guard driven.expectAppears(driven.transcriptText, "the transcript's text") + else { return } + + scrollToTheEnd(of: driven) + dragTheWindowEdge(of: driven, fixture) + + let record = try #require( + fixture.lastTracedInterval(named: Self.drag), + "the app traced no window drag, so the gesture never reached it" + ) + + // Two preconditions before the assertion, because each of them failing + // would leave a test that passes without having tried anything. + #expect( + (record["visible_from_y"] ?? 0) > 1000, + """ + the transcript was still near the top of the document, where the \ + defect does not show: \(record) + """ + ) + #expect( + (record["width_changes"] ?? 0) > 4, + """ + the drag delivered almost no width changes, so it was a jump rather \ + than a gesture: \(record) + """ + ) + + // The assertion. Zero is what the defect produces: the view resized + // hundreds of times and the container was told nothing. + #expect( + (record["container_changes"] ?? 0) > 0, + """ + the text container's width never changed while the window was being \ + dragged, so the text on screen stayed wrapped to the old width \ + until the mouse came up: \(record) + """ + ) + } + + /// Put the transcript at the end of the document. + /// + /// Through the text view's own Command-Down rather than a synthesized + /// scroll wheel: `scroll(byDeltaX:deltaY:)` reported synthesizing an event + /// and left the transcript where it was. The end is used rather than a + /// measured fraction because it is a position the view can be asked for + /// exactly, and anywhere past the first fifth of the document is equally + /// good for what is being tested. + private func scrollToTheEnd(of driven: AppUnderTest) { + driven.transcriptText.click() + driven.app.typeKey(.downArrow, modifierFlags: .command) + } + + /// Drag the window's right edge outwards, once. + /// + /// One direction and no attempt to put the window back. A coordinate is + /// resolved against its element's frame at the moment it is *used*, not + /// when it is made, so a second gesture written against the same two + /// coordinates re-resolves both against the window the first one just + /// narrowed: the return drag starts inside the window body and pulls a + /// stretch of empty transcript instead of the edge. + /// + /// Nothing needs the width restored. This suite launches its own app and + /// terminates it, and the assertion is about the frames during the drag + /// rather than the size it ended on. + /// + /// The window is raised by the click that preceded this, so the edge is + /// where the tree says it is. + private func dragTheWindowEdge(of driven: AppUnderTest, _ fixture: WorkspaceFixture) { + let window = driven.workspaceWindow(fixture) + let edge = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 0.5)) + + edge.press( + forDuration: 0.1, + thenDragTo: edge.withOffset(CGVector(dx: Self.dragBy, dy: 0)) + ) + } + } +} diff --git a/apps/macos/UITests/Transcripts.swift b/apps/macos/UITests/Transcripts.swift new file mode 100644 index 000000000..e0bed3e98 --- /dev/null +++ b/apps/macos/UITests/Transcripts.swift @@ -0,0 +1,36 @@ +/// What each fixture conversation looks like once the app has drawn it. +/// +/// Written out in full rather than assembled from the fixture's messages, so a +/// reader sees exactly what is on screen and a change to the transcript's shape +/// shows up here as a diff. Building these from ``ConversationFixtures`` would +/// follow a change in the app's formatting instead of catching one. +/// +/// The shape: each message is its speaker's name on one line, then the message, +/// with nothing between one message and the next but a newline. The spacing a +/// reader sees is paragraph spacing, which is not in the text. +enum Transcripts { + static let readingList = """ + Jean + What is on the reading list? + Assistant + Three books and a paper. + """ + + static let configPipeline = """ + Jean + How does the config pipeline layer? + Assistant + Later layers win, field by field. + """ + + static let releaseNotes = """ + Jean + Draft the release notes. + Assistant + Drafted, with one open question. + Jean + Answer it yourself. + Assistant + Answered. + """ +} diff --git a/apps/macos/UITests/UISuite.swift b/apps/macos/UITests/UISuite.swift new file mode 100644 index 000000000..e90984386 --- /dev/null +++ b/apps/macos/UITests/UISuite.swift @@ -0,0 +1,13 @@ +import Testing + +/// The suite every UI test belongs to. +/// +/// Serialized, and serialized *together*: `XCUIApplication` addresses the app +/// under test by bundle identifier, so two tests running side by side would +/// drive one process between them. Nesting is what puts sibling suites under +/// the same ordering — `.serialized` orders a suite's own tests and its nested +/// suites, while suites declared alongside each other still run in parallel. +/// +/// The `extension UISuite` declarations in the sibling files are that nesting. +@Suite("UI", .serialized) +struct UISuite {} diff --git a/apps/macos/UITests/WorkspaceFixture.swift b/apps/macos/UITests/WorkspaceFixture.swift new file mode 100644 index 000000000..414fb504e --- /dev/null +++ b/apps/macos/UITests/WorkspaceFixture.swift @@ -0,0 +1,273 @@ +import AppKit +import Foundation + +/// A workspace on disk for one UI test, and the scratch directories the app +/// under test writes into. +/// +/// The layout is JP's storage format: `.jp/.id` names the workspace, and each +/// conversation is a directory holding `metadata.json`, `base_config.json` and +/// `events.json`. A UI test runs outside the app's process and cannot reach the +/// Rust library that would otherwise write them, so they are written here by +/// hand. `crates/jp_ffi/src/lib_tests.rs` pins the same shape from the Rust +/// side; a change to one needs the other. +/// +/// Paired with ``remove()`` through `defer` rather than released by a `deinit`: +/// ARC may drop an object right after its last mention, which can be while the +/// app is still reading the directory. +struct WorkspaceFixture { + /// Everything the fixture owns. + let root: URL + + /// The workspace directory the app is told to open. + let workspacePath: String + + /// The pasteboard the app under test copies to. + /// + /// A real pasteboard that nobody is looking at, so Copy Link can be checked + /// without destroying whatever the person at the keyboard last copied. + /// Named per fixture, so a stale value from an earlier run cannot be read + /// back as this one's. + let pasteboardName: String + + /// The workspace's directory name, which the window shows as its title. + var name: String { + URL(fileURLWithPath: workspacePath).lastPathComponent + } + + /// Create a fixture holding `conversations`. + /// + /// The workspace ID is written rather than left for JP to mint, because JP + /// derives one from the current millisecond. A fixed one keeps the + /// user-local store path stable across runs. + static func make( + named name: String = "my-workspace", + conversations: [FixtureConversation] = [] + ) throws -> WorkspaceFixture { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("jp-uitests-\(UUID().uuidString)") + let workspace = root.appendingPathComponent(name) + let store = workspace.appendingPathComponent(".jp") + + let files = FileManager.default + try files.createDirectory(at: store, withIntermediateDirectories: true) + + // `Id::load` reads the last line, and rejects anything that is not five + // characters of `[0-9a-z]`. + let preamble = "DO NOT EDIT THIS FILE! IT IS AUTO-GENERATED BY JP." + try "\(preamble)\nuitst\n" + .write(to: store.appendingPathComponent(".id"), atomically: true, encoding: .utf8) + + for conversation in conversations { + try conversation.write(into: store) + } + + // The app resolves `HOME` for anything it keeps in the home directory, + // so the directory has to exist before it looks. + for scratch in ["user-data", "state", "home"] { + try files.createDirectory( + at: root.appendingPathComponent(scratch), + withIntermediateDirectories: true + ) + } + + return WorkspaceFixture( + root: root, + workspacePath: workspace.path, + pasteboardName: "computer.jp.jean-pierre.uitest.\(UUID().uuidString)" + ) + } + + /// What to launch the app with, so nothing it writes reaches the state the + /// developer shares with it. + /// + /// - `JP_WORKSPACE` names the workspace to open. The app prefers it over + /// both its stored path and its most recent workspace. + /// - `JP_USER_DATA_DIR` moves the user-local conversation store, which + /// opening a workspace creates. + /// - `JP_DEBUG_STATE_DIR` moves the recent-workspace list into a file here, + /// instead of the list the app shares with the system. That list needs + /// Full Disk Access to read back, so a test could neither inspect nor + /// restore it. + /// - `HOME` moves whatever else the app resolves from the home directory. + /// - `JP_DEBUG_PASTEBOARD` moves Copy Link off the system pasteboard. Read + /// only by a debug build; see `DebugState.pasteboard`. + /// - `JP_DEBUG_DISABLE_ANIMATIONS` stops the app animating. XCUITest waits + /// for the app to stop moving before every action it synthesizes, so an + /// animation is time added to every test that triggers one. + /// + /// Window state saved by `@SceneStorage` reaches none of these, because it + /// is keyed by bundle identifier. ``AppUnderTest`` handles that with a + /// launch argument. + var environment: [String: String] { + [ + "JP_WORKSPACE": workspacePath, + "JP_USER_DATA_DIR": root.appendingPathComponent("user-data").path, + "JP_DEBUG_STATE_DIR": root.appendingPathComponent("state").path, + "HOME": root.appendingPathComponent("home").path, + "JP_DEBUG_PASTEBOARD": pasteboardName, + "JP_DEBUG_DISABLE_ANIMATIONS": "1", + ] + } + + /// The process id of the app launched against this fixture. + /// + /// Written by the app itself, into the state directory it was pointed at. + /// Exact rather than matched on a name or a bundle identifier, which is + /// what makes it safe to act on: the developer's own copy of JP shares both + /// of those and must never be touched. + var appProcessID: String? { + let file = root.appendingPathComponent("state/pid") + guard let text = try? String(contentsOf: file, encoding: .utf8) else { return nil } + + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// The fields of the last interval the app traced under `name`. + /// + /// The app writes one JSON object per line into the state directory it was + /// pointed at, which is how a test reaches a fact about the app that leaves no + /// mark on screen. Live re-wrapping is one: whether text re-wrapped *during* a + /// window drag or only once it ended is invisible afterwards, because both end + /// with the text correct. + /// + /// Numbers come back as `Double` whatever the app wrote, since JSON does not + /// distinguish them and a caller comparing counts does not care. + /// + /// `nil` when the app has traced nothing under that name. + func lastTracedInterval(named name: String) -> [String: Double]? { + let file = root.appendingPathComponent("state/trace.jsonl") + guard let text = try? String(contentsOf: file, encoding: .utf8) else { return nil } + + for line in text.split(separator: "\n").reversed() { + guard + let data = line.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let fields = object["fields"] as? [String: Any], + fields["message"] as? String == name + else { continue } + + return fields.compactMapValues { $0 as? Double ?? ($0 as? Int).map(Double.init) } + } + + return nil + } + + /// The text the app last copied, or `nil` if it has copied nothing. + /// + /// Reads the fixture's own pasteboard, never the system one. That is what + /// makes checking Copy Link safe, and it is enforced rather than trusted: + /// `ClipboardPolicyTests` fails on any mention of the system pasteboard in + /// this directory. + func copiedText() -> String? { + NSPasteboard(name: NSPasteboard.Name(pasteboardName)).string(forType: .string) + } + + func remove() { + // A named pasteboard outlives the process that made one, so this run's + // is handed back rather than left for the pasteboard server to keep. + NSPasteboard(name: NSPasteboard.Name(pasteboardName)).releaseGlobally() + + try? FileManager.default.removeItem(at: root) + } +} + +/// One conversation to write into a fixture. +/// +/// Every value is fixed by the test that builds it, including the ID: JP mints +/// one from the wall clock, and a test that did the same could not name the row +/// it wanted afterwards. +struct FixtureConversation { + /// The decisecond timestamp identifying the conversation. + /// + /// Also its directory name. JP writes `<id>-<slugged title>`, but the loader + /// finds a conversation by the ID prefix, so the bare ID is enough and saves + /// reproducing the slug rule here. + let id: String + + /// The title, shown as the row's first line. + let title: String + + /// When the conversation was last activated, in JP's stored spelling. + /// + /// This is what the list sorts on, most recent first. + let lastActivatedAt: String + + /// When the conversation was pinned, in JP's stored spelling, or `nil` for a + /// conversation that is not pinned. + /// + /// Left out of `metadata.json` entirely when `nil`, which is how JP stores an + /// unpinned conversation and what the app's decoder reads as "not pinned". + var pinnedAt: String? + + /// The stored event stream, oldest first. + let events: [[String: String]] + + /// A message from the user, as storage holds one. + static func userMessage( + at timestamp: String, from author: String, _ text: String + ) -> [String: String] { + ["timestamp": timestamp, "type": "chat_request", "author": author, "content": text] + } + + /// A message from the assistant, as storage holds one. + static func assistantMessage(at timestamp: String, _ text: String) -> [String: String] { + ["timestamp": timestamp, "type": "chat_response", "message": text] + } + + /// The `jp://` URI the app copies and drags for this conversation. + var uri: String { + "jp://\(id)" + } + + /// What the row's second line reads, pluralized the way the app does. + var eventCountLabel: String { + events.count == 1 ? "1 event" : "\(events.count) events" + } + + /// Write the conversation into a workspace's `.jp` store. + fileprivate func write(into store: URL) throws { + let directory = + store + .appendingPathComponent("conversations") + .appendingPathComponent(id) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + + var metadata = ["title": title, "last_activated_at": lastActivatedAt] + if let pinnedAt { + metadata["pinned_at"] = pinnedAt + } + + try Self.writeJSON(metadata, to: directory.appendingPathComponent("metadata.json")) + + try Self.baseConfig.write( + to: directory.appendingPathComponent("base_config.json"), + atomically: true, + encoding: .utf8 + ) + + try Self.writeJSON(events, to: directory.appendingPathComponent("events.json")) + } + + /// The smallest `base_config.json` a conversation can be stored with. + /// + /// Its presence tells the loader the conversation is in the current storage + /// format, and its contents have to finalize into a whole config: a + /// conversation whose base config is empty fails to load, and the app shows + /// "Could Not Read Conversation" where the transcript belongs. These two + /// settings are the ones with no default to fall back on. + /// + /// The same string is pinned in `crates/jp_ffi/src/lib_tests.rs`, which + /// reads this exact layout back through the library the app calls. That + /// test is what names a newly required setting, in seconds; here the same + /// breakage looks like a UI test waiting on a pane that never fills. + private static let baseConfig = """ + {"assistant":{"model":{"id":{"provider":"anthropic","name":"test"}}},\ + "conversation":{"tools":{"*":{"run":"ask"}}}} + """ + + private static func writeJSON(_ value: Any, to url: URL) throws { + let data = try JSONSerialization.data(withJSONObject: value) + try data.write(to: url) + } +} diff --git a/apps/macos/project.yml b/apps/macos/project.yml index 9105ade0b..ff3045a04 100644 --- a/apps/macos/project.yml +++ b/apps/macos/project.yml @@ -135,12 +135,34 @@ targets: TEST_HOST: $(BUILT_PRODUCTS_DIR)/JP.app/Contents/MacOS/JP BUNDLE_LOADER: $(TEST_HOST) + # The regression half of `QA.md`: the app is launched, acted on, and read back + # through its accessibility tree, which is what reaches menu enablement, the + # pasteboard, and terminate-and-relaunch. + # + # A UI test runs in its own process, so `@testable import JP` is not available + # here and must not be reached for. Anything that can be checked in-process + # belongs in JPTests, where it runs in milliseconds. + JPUITests: + type: bundle.ui-testing + platform: macOS + sources: + - path: UITests + dependencies: + - target: JP + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: computer.jp.jean-pierre.uitests + GENERATE_INFOPLIST_FILE: YES + # Which app `XCUIApplication()` addresses when constructed without one. + TEST_TARGET_NAME: JP + schemes: JP: build: targets: JP: all JPTests: [test] + JPUITests: [test] run: config: Debug # The workspace to open. Phase 2 has no file chooser, so the path comes @@ -156,3 +178,4 @@ schemes: gatherCoverageData: false targets: - JPTests + - JPUITests diff --git a/justfile b/justfile index 93fbf41e5..34a114858 100644 --- a/justfile +++ b/justfile @@ -344,6 +344,36 @@ test-app: gen-app (build-ffi "debug") xcodebuild test -project apps/macos/JP.xcodeproj -scheme JP \ -destination platform=macOS -only-testing:JPTests -quiet +# Run every one of the macOS app's UI tests. +# +# Takes over the screen for the length of the run. This is the CI job; while +# writing a test, run it by name through the `swift_test_ui` tool instead, which +# stops at the first failure. +# +# Every test runs here even after one fails, which is what `CI` means to that +# tool and what a run nobody is watching should do. +[group('test')] +[macos] +test-app-ui: gen-app (build-ffi "debug") + CI=1 xcodebuild test -project apps/macos/JP.xcodeproj -scheme JP \ + -destination platform=macOS -only-testing:JPUITests -quiet + +# Format the macOS app's Swift sources. +[group('fmt')] +[macos] +fmt-app: + swift format --in-place --recursive --parallel \ + apps/macos/Sources apps/macos/Tests apps/macos/UITests \ + apps/macos/Tools/jpdrive/Sources apps/macos/Tools/jpdrive/Tests + +# Check Swift formatting and lints without rewriting anything. +[group('check')] +[macos] +lint-app: + swift format lint --strict --recursive --parallel \ + apps/macos/Sources apps/macos/Tests apps/macos/UITests \ + apps/macos/Tools/jpdrive/Sources apps/macos/Tools/jpdrive/Tests + [group('profile')] [positional-arguments] profile-heap *ARGS: From 53a8d3b8f0db182e08960a6d4303b88635421218 Mon Sep 17 00:00:00 2001 From: Jean Mertz <git@jeanmertz.com> Date: Wed, 19 Aug 2026 10:07:49 +0200 Subject: [PATCH 8/8] ci(github): Build and test the macOS app Nothing in CI compiled the Swift half of the repository, so the app, the UI tests and the jpdrive package were verified only by whoever remembered to run them locally. The strict compiler settings and warnings-as-errors the app is held to are worth little if no automated run enforces them. Runs as its own workflow rather than as entries in the Rust matrix, whose rustup, sccache and target caching two of these four tasks have no use for. Each task is gated on the paths that can break it: the two Swift-only tasks watch \`apps/macos\`, and \`test-app\` watches Rust as well, because the app links \`jp_ffi\` and a change in a crate beneath it can break the build without touching a Swift file. Signed-off-by: Jean Mertz <git@jeanmertz.com> --- .github/workflows/app.yml | 134 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 .github/workflows/app.yml diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml new file mode 100644 index 000000000..d1d8118be --- /dev/null +++ b/.github/workflows/app.yml @@ -0,0 +1,134 @@ +name: app +on: + pull_request: + push: + branches: + - main +env: + CARGO_TERM_COLOR: always + JUST_TIMESTAMP: true + JUST_COLOR: always + JUST_EXPLAIN: true + JUST_VERBOSE: 4 +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +jobs: + changes: + runs-on: ubuntu-latest + outputs: + lint-app: ${{ steps.filter.outputs.lint-app }} + test-drive: ${{ steps.filter.outputs.test-drive }} + test-app: ${{ steps.filter.outputs.test-app }} + test-app-ui: ${{ steps.filter.outputs.test-app-ui }} + steps: + - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v6 + with: + fetch-depth: 0 + - id: filter + shell: bash + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "pull_request" ]; then + git diff --name-only HEAD^1 HEAD > changed-files.txt + else + before="${{ github.event.before }}" + if [ "$before" = "0000000000000000000000000000000000000000" ]; then + git diff-tree --no-commit-id --name-only -r HEAD > changed-files.txt + else + git diff --name-only "$before" HEAD > changed-files.txt + fi + fi + + matches() { + grep -Eq "$1" changed-files.txt + } + + set_task() { + if matches "$2"; then + echo "$1=true" >> "$GITHUB_OUTPUT" + else + echo "$1=false" >> "$GITHUB_OUTPUT" + fi + } + + workflow='^\.github/workflows/app\.yml$' + justfile='^justfile$' + swift='^apps/macos/' + drive='^apps/macos/Tools/' + # The app links `jp_ffi`, which links most of the workspace, so a + # Rust change anywhere can break the build it cannot break the Swift + # sources. `test-app` therefore watches Rust as well; the two Swift- + # only tasks do not. + rust='\.rs$|(^|/)Cargo\.toml$|^Cargo\.lock$|^rust-toolchain\.toml$' + + set_task lint-app "$swift|$workflow|$justfile" + set_task test-drive "$drive|$workflow|$justfile" + set_task test-app "$swift|$rust|$workflow|$justfile" + set_task test-app-ui "$swift|$workflow|$justfile" + app: + needs: changes + runs-on: macos-latest + strategy: + # One failing task should not cancel the others: a formatting failure and + # a test failure are separate pieces of information, and a macOS run is + # too slow to pay for twice. + fail-fast: false + matrix: + task: [lint-app, test-drive, test-app, test-app-ui] + name: ${{ matrix.task }} + # The UI tests drive the app through the screen and are the slowest thing + # here by a wide margin. Cap the job so a hung run does not burn macOS + # minutes unnoticed. + timeout-minutes: 45 + steps: + - name: Decide whether to run task + id: task + shell: bash + env: + TASK: ${{ matrix.task }} + LINT_APP: ${{ needs.changes.outputs.lint-app }} + TEST_DRIVE: ${{ needs.changes.outputs.test-drive }} + TEST_APP: ${{ needs.changes.outputs.test-app }} + TEST_APP_UI: ${{ needs.changes.outputs.test-app-ui }} + run: | + set -euo pipefail + + case "$TASK" in + lint-app) run="$LINT_APP" ;; + test-drive) run="$TEST_DRIVE" ;; + test-app) run="$TEST_APP" ;; + test-app-ui) run="$TEST_APP_UI" ;; + *) echo "unknown task: $TASK" >&2; exit 1 ;; + esac + + echo "run=$run" >> "$GITHUB_OUTPUT" + if [ "$run" != "true" ]; then + echo "Skipping $TASK because no relevant files changed." + fi + - if: ${{ steps.task.outputs.run == 'true' }} + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v6 + - if: ${{ steps.task.outputs.run == 'true' }} + uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 + # Recorded on every run so a failure that turns out to be a toolchain + # difference is diagnosable from the log rather than by bisecting the + # runner image. + - name: Report toolchain versions + if: ${{ steps.task.outputs.run == 'true' }} + run: | + xcodebuild -version + swift --version + # Only the tasks that build the app need the project generated, and only + # they need cargo: `lint-app` and `test-drive` touch neither. + - if: ${{ steps.task.outputs.run == 'true' && (matrix.task == 'test-app' || matrix.task == 'test-app-ui') }} + run: brew install xcodegen + - if: ${{ steps.task.outputs.run == 'true' && (matrix.task == 'test-app' || matrix.task == 'test-app-ui') }} + uses: Swatinem/rust-cache@7e1e2d0a10862b34e5df481373b2b0f295d1a2ef # v2 + with: + key: ${{ matrix.task }} + cache-bin: false + cache-workspace-crates: true + save-if: ${{ github.ref == 'refs/heads/main' }} + - if: ${{ steps.task.outputs.run == 'true' }} + run: just ${{ matrix.task }}