diff --git a/.config/jp/tools/Cargo.toml b/.config/jp/tools/Cargo.toml index 0649f20a9..978dcd30a 100644 --- a/.config/jp/tools/Cargo.toml +++ b/.config/jp/tools/Cargo.toml @@ -19,6 +19,7 @@ jp_md = { workspace = true } jp_tool = { workspace = true } jp_workspace = { workspace = true } ticket = { workspace = true } +xct2cli = { workspace = true } base64 = { workspace = true, features = ["std"] } camino = { workspace = true } diff --git a/.config/jp/tools/src/debug_app.rs b/.config/jp/tools/src/debug_app.rs new file mode 100644 index 000000000..bb7599bcb --- /dev/null +++ b/.config/jp/tools/src/debug_app.rs @@ -0,0 +1,90 @@ +//! Assistant-callable tools for driving the macOS app. +//! +//! `debug_jp` inverted. +//! There `jp` is short-lived and a tool wraps a whole run; here the app +//! outlives the call, so the running instance *is* the session and each tool +//! attaches to it. +//! [`session`] holds that record and refuses to act on an instance that is not +//! the one which was launched. +//! +//! Tools currently exposed: +//! +//! - `debug_app_launch` — build, launch an isolated instance, record the +//! session. +//! +//! - `debug_app_snapshot` — the accessibility tree, the console delta, a +//! summary of what the app traced, and the pasteboard. +//! +//! - `debug_app_screenshot` — a PNG of the app's window, for what the tree +//! cannot express. +//! +//! - `debug_app_pixels` — the colours along one row or column of that window, +//! for the drawn things that carry neither a frame nor a colour in the tree. +//! +//! - `debug_app_drive` — run a step list, reporting what each step changed. +//! +//! - `debug_app_quit` — stop it, keeping its state for a relaunch. +//! +//! - `debug_app_profile` — open and close an Instruments recording around the +//! operation in question, and read back what a session recorded. +//! +//! Two tiers of performance data, and only the cheaper one is unconditional. +//! The app instruments itself into `trace.jsonl` on every run, and every +//! snapshot reports those intervals and its footprint. +//! Instruments is the escalation from there: [`profile`] brackets it, +//! [`capture`] runs the recorder and bounds what is kept, and [`hotspots`] +//! reduces a bundle to a summary. +//! +//! [`report`] reads both tiers back at a scope a caller chooses — [`stream`] +//! parses the app's own intervals, [`marks`] says which driven step caused +//! them. +//! It needs no session, because `debug_app_quit` removes that record and +//! reading a run afterwards is the ordinary case. +//! +//! [`steps`] holds the action vocabulary, as data, independent of the harness +//! that walks a list. +//! [`drive`] is the harness that walks one live; a harness that runs the same +//! list under a profiler would need no second copy of the vocabulary. +//! +//! Isolation is by environment and covers the app's recent-workspace list and +//! its conversation store. +//! It does not cover window state saved by `@SceneStorage`, which is keyed by +//! bundle identifier. +//! See [`launch`] for what each variable does and why. + +use crate::{ + Context, Tool, + util::{ToolResult, unknown_tool}, +}; + +pub(crate) mod ambient; +pub(crate) mod capture; +pub(crate) mod drive; +pub(crate) mod driver; +pub(crate) mod hotspots; +pub(crate) mod launch; +pub(crate) mod marks; +pub(crate) mod pixels; +pub(crate) mod profile; +pub(crate) mod quit; +pub(crate) mod report; +pub(crate) mod screenshot; +pub(crate) mod session; +pub(crate) mod snapshot; +pub(crate) mod steps; +pub(crate) mod stream; +pub(crate) mod trace; +pub(crate) mod tree; + +pub async fn run(ctx: Context, t: Tool) -> ToolResult { + match t.name.trim_start_matches("debug_app_") { + "drive" => drive::debug_app_drive(&ctx, &t).await, + "launch" => launch::debug_app_launch(&ctx, &t).await, + "pixels" => pixels::debug_app_pixels(&ctx, &t).await, + "profile" => profile::debug_app_profile(&ctx, &t).await, + "quit" => quit::debug_app_quit(&ctx, &t).await, + "screenshot" => screenshot::debug_app_screenshot(&ctx, &t).await, + "snapshot" => snapshot::debug_app_snapshot(&ctx, &t).await, + _ => unknown_tool(t), + } +} diff --git a/.config/jp/tools/src/debug_app/ambient.rs b/.config/jp/tools/src/debug_app/ambient.rs new file mode 100644 index 000000000..9ed7055b7 --- /dev/null +++ b/.config/jp/tools/src/debug_app/ambient.rs @@ -0,0 +1,97 @@ +//! Borrowing and returning the state a driven run does not own. +//! +//! Which application is in front, and where the pointer is. +//! A step that synthesizes input has to take both: mouse events go to whatever +//! is on top at a coordinate, and the ordering between applications follows +//! activation. +//! Both belong to whoever is at the keyboard. +//! +//! Run-scoped, and it has to be: `jpdrive` runs one step per process, so +//! nothing on that side outlives a single step. +//! Restoring there would put focus back between every pair of steps and leave +//! the next one aiming at a window that is no longer in front. +//! +//! Deliberately not window geometry. +//! A step that resized a window did the thing it was asked to do, and putting +//! the window back would undo the effect the run was measuring. + +use camino::Utf8Path; +use serde::Deserialize; + +use crate::util::runner::ProcessRunner; + +/// What a run borrowed, to be handed back when it ends. +/// +/// Either field is `None` when the driver could not report it. +/// A restore skips what it does not know rather than guessing, because guessing +/// here moves the pointer of somebody who is using it. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct Borrowed { + frontmost: Option, + pointer: Option<(f64, f64)>, +} + +#[derive(Deserialize)] +struct FrontmostReport { + bundle_id: Option, +} + +#[derive(Deserialize)] +struct PointerReport { + x: f64, + y: f64, +} + +/// Read what is about to be borrowed. +/// +/// Never fails the run. +/// A driver that cannot report the frontmost application is a reason to leave +/// focus alone afterwards, not a reason to refuse to drive. +pub(crate) fn capture(bin: &Utf8Path, root: &Utf8Path, runner: &dyn ProcessRunner) -> Borrowed { + Borrowed { + frontmost: read::(bin, &["frontmost"], root, runner) + .and_then(|report| report.bundle_id), + pointer: read::(bin, &["pointer"], root, runner) + .map(|report| (report.x, report.y)), + } +} + +/// Put back what was borrowed. +/// +/// Focus first, then the pointer: activating an application does not move the +/// cursor, so the order only matters in that the pointer must not be placed and +/// then have an activation drag it elsewhere. +/// +/// Silent about failure for the same reason as [`capture`]: this runs after the +/// work a caller asked for, and a complaint here would replace whatever the run +/// was reporting. +pub(crate) fn restore( + borrowed: &Borrowed, + bin: &Utf8Path, + root: &Utf8Path, + runner: &dyn ProcessRunner, +) { + if let Some(bundle_id) = &borrowed.frontmost { + let _restored = runner.run(bin.as_str(), &["frontmost", "--set", bundle_id], root); + } + + if let Some((x, y)) = borrowed.pointer { + let point = format!("{x},{y}"); + let _restored = runner.run(bin.as_str(), &["pointer", "--set", &point], root); + } +} + +/// Run one driver subcommand and read its JSON document. +fn read Deserialize<'de>>( + bin: &Utf8Path, + args: &[&str], + root: &Utf8Path, + runner: &dyn ProcessRunner, +) -> Option { + let output = runner.run(bin.as_str(), args, root).ok()?; + serde_json::from_str(&output.stdout).ok() +} + +#[cfg(test)] +#[path = "ambient_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/ambient_tests.rs b/.config/jp/tools/src/debug_app/ambient_tests.rs new file mode 100644 index 000000000..f86cc27c2 --- /dev/null +++ b/.config/jp/tools/src/debug_app/ambient_tests.rs @@ -0,0 +1,84 @@ +use camino::Utf8Path; + +use super::*; +use crate::util::runner::MockProcessRunner; + +fn root() -> &'static Utf8Path { + Utf8Path::new("/tmp") +} + +fn bin() -> &'static Utf8Path { + Utf8Path::new("/tmp/jpdrive") +} + +#[test] +fn captures_what_the_driver_reports() { + let runner = MockProcessRunner::builder() + .expect("/tmp/jpdrive") + .args(&["frontmost"]) + .returns_success(r#"{"bundle_id":"com.apple.Terminal"}"#) + .expect("/tmp/jpdrive") + .args(&["pointer"]) + .returns_success(r#"{"x":412.5,"y":88}"#); + + assert_eq!(capture(bin(), root(), &runner), Borrowed { + frontmost: Some("com.apple.Terminal".to_owned()), + pointer: Some((412.5, 88.0)), + }); +} + +/// A driver that cannot say what is in front leaves focus alone afterwards. +/// Refusing to drive over it would be worse: the steps are what the caller +/// asked for, and this is housekeeping around them. +#[test] +fn captures_nothing_when_the_driver_reports_nothing() { + let runner = MockProcessRunner::success("not json at all"); + + assert_eq!(capture(bin(), root(), &runner), Borrowed::default()); +} + +#[test] +fn restores_focus_and_the_pointer() { + let runner = MockProcessRunner::builder() + .expect("/tmp/jpdrive") + .args(&["frontmost", "--set", "com.apple.Terminal"]) + .returns_success("{}") + .expect("/tmp/jpdrive") + .args(&["pointer", "--set", "412.5,88"]) + .returns_success("{}"); + + restore( + &Borrowed { + frontmost: Some("com.apple.Terminal".to_owned()), + pointer: Some((412.5, 88.0)), + }, + bin(), + root(), + &runner, + ); +} + +/// Nothing borrowed, nothing put back: a run that captured no pointer must not +/// warp the cursor to a coordinate it invented. +/// +/// Asserted by expecting the one call that is owed and nothing else. +/// The mock fails on an unfulfilled expectation, so the frontmost call has to +/// happen, and it has no expectation to match a pointer call — which is what +/// makes the absence of one an assertion rather than an omission. +#[test] +fn restores_only_what_it_captured() { + let runner = MockProcessRunner::builder() + .expect("/tmp/jpdrive") + .args(&["frontmost", "--set", "com.apple.Terminal"]) + .returns_success("{}"); + + restore( + &Borrowed { + frontmost: Some("com.apple.Terminal".to_owned()), + pointer: None, + }, + bin(), + root(), + &runner, + ); +} diff --git a/.config/jp/tools/src/debug_app/capture.rs b/.config/jp/tools/src/debug_app/capture.rs new file mode 100644 index 000000000..f0301efe6 --- /dev/null +++ b/.config/jp/tools/src/debug_app/capture.rs @@ -0,0 +1,915 @@ +//! The Instruments recording a profile bracket runs. +//! +//! A recording is a bracket inside a driven session rather than a property of +//! one: `debug_app_profile` opens it, a later call closes it, and a session can +//! hold several in sequence. +//! That is what keeps a report about the operation someone asked about rather +//! than about a mostly-idle app, and what keeps the recorder out of +//! `debug_app_launch` and `debug_app_quit`. +//! +//! Scope follows from when the bracket opens. +//! With a session already running there is a process to attach to and the trace +//! holds that process alone. +//! With no session there is nothing to attach to, so the recorder takes the +//! whole machine — the only way to cover an app's own startup, and minutes of +//! work at analysis time, because every process's samples are exported before +//! ours can be filtered out of them. +//! +//! Allocation attribution is the one tier the scope cannot give you. +//! The Allocations instrument refuses a target of all processes, so it is +//! reachable only by attaching — and attaching means the app is already +//! running, which it must have been launched with `MallocStackLogging` to be +//! any use, because libmalloc reads that at process start. +//! So `debug_app_launch` decides what an app is able to report, and a bracket +//! decides what is recorded of it. +//! +//! The recorder outlives the process that starts it — `start` and `stop` are +//! separate runs of this binary — so [`Recording`] is written to disk beside +//! the bundle, and [`stop`] reaches the recorder through a signal. +//! +//! Scope also decides what survives being read. +//! A system-wide bundle embeds the environment of every process on the machine, +//! so it is destroyed and only its summary is kept. +//! An attach bundle embeds this app's alone, so it stays, which is what makes +//! re-scoping and comparing two runs possible — bounded by an age window and a +//! byte budget, whichever bites first, oldest evicted first. +//! The same window and budget cover the app's own interval streams, archived +//! here one per run. + +use std::{ + fs, + process::{Command, Stdio}, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; +use xct2cli::Slide; + +use crate::{ + Error, + debug_app::{ + marks, + session::{Session, Signal, Signals}, + }, +}; + +/// The recorder, resolved through `xcrun` so it follows the selected Xcode. +const RECORDER: &str = "xcrun"; + +/// Where every recording's artifacts live, inside a slot's directory. +const PROFILES_DIR: &str = "profiles"; + +/// How long to wait for the recorder to report that it is recording. +pub(crate) const READY_TIMEOUT: Duration = Duration::from_mins(1); + +/// How long the recorder gets to write the bundle out after `SIGINT`. +/// +/// Generous, because finalizing is minutes of real work for a system-wide +/// recording and there is no way to shorten it: a recorder cut off partway +/// leaves a bundle nothing can open. +pub(crate) const FINALIZE_TIMEOUT: Duration = Duration::from_mins(5); + +/// How long a bracket nobody closed stays reachable before it is reclaimed. +/// +/// Whether the recorder is still alive deliberately does not enter into it. +/// A recorder that failed on its own still wrote a bundle and still said why in +/// its log, and treating that as abandoned would destroy the diagnostic along +/// with it — which is exactly how a failed recording becomes invisible. +/// Age is the discriminator instead. +/// +/// Two days rather than hours, because the data is gone for good once this +/// elapses: long enough to come back the next morning, notice a bracket that +/// failed, and still be able to read what it recorded. +const PENDING_WINDOW: Duration = Duration::from_hours(48); + +/// How long a closed recording's artifacts stay readable. +/// +/// The same span as [`PENDING_WINDOW`], for the same reason: long enough to +/// come back the next morning and ask a second question of what a run recorded. +const RETENTION_WINDOW: Duration = Duration::from_hours(48); + +/// How many bytes of retained artifacts one slot keeps. +/// +/// Age bounds nothing about size. +/// One system-wide recording pulled in around 450 symbol archives, and filling +/// the disk is a demonstrated failure here rather than a hypothetical one, so +/// the two limits run together and whichever bites first evicts — oldest +/// first, either way. +const RETENTION_BUDGET: u64 = 2 * 1024 * 1024 * 1024; + +/// Extension on an archived stream of the app's own intervals. +const STREAM_EXTENSION: &str = "jsonl"; + +/// Prefix naming an archived stream, matching [`new_id`]'s shape for a +/// recording. +const STREAM_PREFIX: &str = "trace-"; + +/// Poll interval while waiting on the recorder. +const POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// What the recorder prints when a run it recorded misbehaved. +const RUN_ISSUES_MARKER: &str = "Run issues were detected"; + +/// One instrument a recording holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Tier { + /// Time Profiler: periodic backtraces, attributed per core and per pid. + Sampling, + + /// Allocations: every allocation with the stack that made it, which needs + /// `MallocStackLogging` in the target's environment. + Allocations, +} + +impl Tier { + /// The Instruments instrument name. + const fn instrument(self) -> &'static str { + match self { + Tier::Sampling => "Time Profiler", + Tier::Allocations => "Allocations", + } + } + + /// The name a caller writes and a report prints. + const fn label(self) -> &'static str { + match self { + Tier::Sampling => "sampling", + Tier::Allocations => "allocations", + } + } +} + +/// The tiers a `capture` argument asks for. +/// +/// Sampling is always in the result and cannot be named: it has no toggle, so +/// accepting the word would imply one. +/// Saying that is better than ignoring it, because a caller who passes +/// `["sampling"]` believes they turned something on. +pub(crate) fn parse_tiers(requested: &[String]) -> Result, Error> { + let mut tiers = vec![Tier::Sampling]; + + for name in requested { + match name.as_str() { + "allocations" => { + if !tiers.contains(&Tier::Allocations) { + tiers.push(Tier::Allocations); + } + } + "sampling" => { + return Err( + "`capture` does not accept \"sampling\": every recording holds a time \ + profile, so there is nothing to ask for. Pass `[]` for that alone, or \ + `[\"allocations\"]` to add allocation attribution." + .into(), + ); + } + other => { + return Err(format!( + "`capture` does not accept {other:?}. The only value is \"allocations\"; \ + sampling is always recorded." + ) + .into()); + } + } + } + + Ok(tiers) +} + +/// What the recorder is pointed at. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Scope { + /// One running process, named by pid. + Attach(u32), + + /// Every process on the machine. + System, +} + +impl Scope { + /// Whether analysis has to sift other processes out of this trace. + pub(crate) const fn is_system(self) -> bool { + matches!(self, Scope::System) + } +} + +/// The app a recording is attributed to. +/// +/// Everything symbolication needs, in one place that survives the session. +/// `debug_app_quit` removes the session record, and reading a recording after +/// quitting is the ordinary case rather than an edge, so a closed bracket +/// writes this into its own sidecar and answers for itself from then on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Target { + pub pid: u32, + + /// The executable inside the launched bundle. + pub binary: Utf8PathBuf, + + /// The dSYM the build produced, when it produced one. + pub dsym: Option, + + /// The ASLR slide the app reported for its own main image. + /// + /// `None` falls back to recovering it from the trace's image-load events, + /// which only works for a recording that was already running when the app's + /// images were mapped. + pub slide: Option, + + pub configuration: String, +} + +impl Target { + /// What a running session says about the app a bracket is recording. + pub(crate) fn for_session(session: &Session) -> Target { + Target { + pid: session.pid, + binary: app_binary(&session.bundle), + dsym: session.dsym.clone(), + slide: session.reported_slide(), + configuration: session.configuration.clone(), + } + } +} + +/// The executable inside a launched app bundle. +/// +/// Named after the bundle, which is what Xcode does and what staging preserves. +fn app_binary(bundle: &Utf8Path) -> Utf8PathBuf { + let name = bundle.file_stem().unwrap_or("JP"); + + bundle.join("Contents/MacOS").join(name) +} + +/// One recording, as written beside its bundle. +/// +/// On disk rather than in the session record, because a recording can open +/// before a session exists and can outlive the record `debug_app_quit` removes. +/// It is also what lets a sweep tell an abandoned bundle from one still being +/// written. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Recording { + /// Names this recording, and every file belonging to it. + pub id: String, + + /// The instruments being recorded. + pub tiers: Vec, + + /// What the recorder was pointed at. + pub scope: Scope, + + /// The `xctrace` process writing the bundle. + pub recorder_pid: u32, + + /// When the bracket opened, in seconds since the epoch. + pub started_unix: u64, + + /// When the bracket closed, if it has. + /// + /// Stamped before the bundle is read, so a read that fails leaves a closed + /// recording rather than a bracket that looks open forever and blocks the + /// next one. + #[serde(default)] + pub stopped_unix: Option, + + /// The app this recording is attributed to, as far as it is known. + /// + /// Written when the bracket closes. + /// `None` for a bracket nothing was ever launched into, which leaves the + /// samples with nothing to attribute. + #[serde(default)] + pub target: Option, +} + +impl Recording { + /// Whether this recording holds `tier`. + pub(crate) fn holds(&self, tier: Tier) -> bool { + self.tiers.contains(&tier) + } + + /// The tiers, as a phrase for a report. + pub(crate) fn describe(&self) -> String { + self.tiers + .iter() + .map(|t| t.label()) + .collect::>() + .join(", ") + } + + pub(crate) fn bundle(&self, dir: &Utf8Path) -> Utf8PathBuf { + profiles_dir(dir).join(format!("{}.trace", self.id)) + } + + pub(crate) fn log(&self, dir: &Utf8Path) -> Utf8PathBuf { + profiles_dir(dir).join(format!("{}.log", self.id)) + } + + pub(crate) fn sidecar(&self, dir: &Utf8Path) -> Utf8PathBuf { + profiles_dir(dir).join(format!("{}.json", self.id)) + } + + pub(crate) fn summary(&self, dir: &Utf8Path) -> Utf8PathBuf { + profiles_dir(dir).join(format!("{}.md", self.id)) + } + + /// What the recorder said, as far as it has been written. + pub(crate) fn said(&self, dir: &Utf8Path) -> String { + fs::read_to_string(self.log(dir)).unwrap_or_default() + } + + /// Write the record beside its bundle. + pub(crate) fn store(&self, dir: &Utf8Path) -> Result<(), Error> { + let path = self.sidecar(dir); + fs::create_dir_all(profiles_dir(dir))?; + let json = serde_json::to_string_pretty(self)?; + + fs::write(&path, format!("{json}\n")) + .map_err(|e| format!("Failed to write {path}: {e}").into()) + } + + /// Whether this bracket is still open. + /// + /// A stop stamp or a summary means it already closed, whatever else is on + /// disk. + /// Liveness of the recorder says only whether stopping it needs a signal, + /// not whether there is anything to stop for. + pub(crate) fn is_pending(&self, dir: &Utf8Path) -> bool { + self.stopped_unix.is_none() && !self.summary(dir).exists() && self.age() < PENDING_WINDOW + } + + /// Record that the bracket closed, and what it was recording. + pub(crate) fn close(&mut self, target: Option, dir: &Utf8Path) -> Result<(), Error> { + self.stopped_unix = Some(unix_seconds()); + self.target = target; + + self.store(dir) + } + + /// How long ago the bracket opened. + fn age(&self) -> Duration { + Duration::from_secs(unix_seconds().saturating_sub(self.started_unix)) + } + + /// Delete the bundle, the recorder's output, and this record. + /// + /// The summary is left: it is the point of the exercise, and it holds + /// nothing the bundle held. + pub(crate) fn discard(&self, dir: &Utf8Path) -> Result<(), Error> { + self.discard_bundle(dir)?; + remove_file(&self.sidecar(dir))?; + + Ok(()) + } + + /// Delete the bundle and the recorder's output, keeping the record. + /// + /// The record is what attributes everything else, so it outlives the + /// bundle: a report can still say which app a summary belongs to, and why + /// the bundle itself is not there to re-read. + pub(crate) fn discard_bundle(&self, dir: &Utf8Path) -> Result<(), Error> { + let bundle = self.bundle(dir); + remove_dir(&bundle).map_err(|e| { + format!( + "Failed to delete the trace bundle at {bundle}: {e}. It holds the environment of \ + every process it recorded — delete it by hand and do not attach it to anything." + ) + })?; + + remove_file(&self.log(dir)) + } + + /// Whether this recording's bundle is safe to keep once it has been read. + /// + /// A bundle embeds the environment of every process it recorded. + /// Recorded system-wide, that is the whole machine's, so it goes. + /// Recorded by attaching, it is this app's alone — launchd's environment + /// plus the values the launch passed — and keeping it is what makes + /// re-scoping, comparing two runs, and recovering from a bad read possible + /// at all. + pub(crate) const fn keeps_bundle(&self) -> bool { + !self.scope.is_system() + } + + /// Close out a read recording, destroying whatever must not be kept. + pub(crate) fn retire(&self, dir: &Utf8Path) -> Result<(), Error> { + if self.keeps_bundle() { + return Ok(()); + } + + self.discard_bundle(dir) + } + + /// How many bytes this recording occupies on disk. + fn bytes(&self, dir: &Utf8Path) -> u64 { + dir_bytes(&self.bundle(dir)) + file_bytes(&self.log(dir)) + file_bytes(&self.sidecar(dir)) + } +} + +/// Where every recording's artifacts live. +pub(crate) fn profiles_dir(dir: &Utf8Path) -> Utf8PathBuf { + dir.join(PROFILES_DIR) +} + +/// The open bracket, if there is one. +/// +/// At most one: opening a second is refused. +pub(crate) fn pending(dir: &Utf8Path) -> Option { + recordings(dir) + .into_iter() + .find(|recording| recording.is_pending(dir)) +} + +/// Every recording this slot has a record of. +pub(crate) fn recordings(dir: &Utf8Path) -> Vec { + let Ok(entries) = fs::read_dir(profiles_dir(dir)) else { + return Vec::new(); + }; + + let mut out = Vec::new(); + for entry in entries.flatten() { + let path = Utf8PathBuf::from_path_buf(entry.path()).unwrap_or_default(); + if path.extension() != Some("json") { + continue; + } + + if let Ok(raw) = fs::read_to_string(&path) + && let Ok(recording) = serde_json::from_str::(&raw) + { + out.push(recording); + } + } + + out.sort_by(|a, b| a.id.cmp(&b.id)); + out +} + +/// Reclaim what is no longer worth keeping, and report which ids went. +/// +/// Three rules, in order. +/// +/// An open bracket survives untouched — including one whose recorder failed, +/// which still has a bundle and a reason worth reading. +/// +/// A closed system-wide recording loses its bundle immediately, whatever its +/// age: that bundle embeds the environment of every process on the machine, so +/// one left behind is credential material sitting on disk. +/// +/// Everything else is retained and then bounded, by [`RETENTION_WINDOW`] and +/// [`RETENTION_BUDGET`] together, oldest evicted first. +/// The step boundaries `debug_app_drive` records expire on the same window, by +/// line rather than by file: one file holds every run a slot has driven. +/// +/// Summaries are never swept. +/// They are small, they are the product, and they hold none of what the bundle +/// held. +pub(crate) fn sweep(dir: &Utf8Path) -> Vec { + let mut swept = Vec::new(); + let mut retained = Vec::new(); + + let expired = marks::sweep(dir, RETENTION_WINDOW); + if expired > 0 { + swept.push(format!("{expired} expired step boundaries")); + } + + for recording in recordings(dir) { + if recording.is_pending(dir) { + continue; + } + + if !recording.keeps_bundle() + && recording.bundle(dir).exists() + && recording.discard_bundle(dir).is_ok() + { + swept.push(recording.id.clone()); + } + + retained.push(Artifact::Bundle(recording)); + } + + retained.extend(streams(dir).into_iter().map(Artifact::Stream)); + + swept.extend(orphaned_bundles(dir)); + swept.extend(enforce_limits(dir, retained, RETENTION_BUDGET)); + swept +} + +/// One thing a slot keeps after the run that produced it. +enum Artifact { + /// A recording, and its bundle when that was kept. + Bundle(Recording), + + /// One earlier run's stream of the app's own intervals. + Stream(Utf8PathBuf), +} + +impl Artifact { + fn id(&self) -> String { + match self { + Artifact::Bundle(recording) => recording.id.clone(), + Artifact::Stream(path) => path.file_stem().unwrap_or_default().to_owned(), + } + } + + /// When the run that produced this began, in seconds since the epoch. + fn started_unix(&self) -> u64 { + match self { + Artifact::Bundle(recording) => recording.started_unix, + Artifact::Stream(path) => stream_started_unix(path), + } + } + + fn bytes(&self, dir: &Utf8Path) -> u64 { + match self { + Artifact::Bundle(recording) => recording.bytes(dir), + Artifact::Stream(path) => file_bytes(path), + } + } + + fn evict(&self, dir: &Utf8Path) -> Result<(), Error> { + match self { + Artifact::Bundle(recording) => recording.discard(dir), + Artifact::Stream(path) => remove_file(path), + } + } +} + +/// Evict retained artifacts until both limits hold, oldest first. +fn enforce_limits(dir: &Utf8Path, mut retained: Vec, budget: u64) -> Vec { + let mut swept = Vec::new(); + retained.sort_by_key(Artifact::started_unix); + + let now = unix_seconds(); + let mut kept = Vec::new(); + let mut total = 0_u64; + + for artifact in retained { + if now.saturating_sub(artifact.started_unix()) > RETENTION_WINDOW.as_secs() { + if artifact.evict(dir).is_ok() { + swept.push(artifact.id()); + } + continue; + } + + let bytes = artifact.bytes(dir); + total = total.saturating_add(bytes); + kept.push((artifact, bytes)); + } + + for (artifact, bytes) in kept { + if total <= budget { + break; + } + + if artifact.evict(dir).is_ok() { + total = total.saturating_sub(bytes); + swept.push(artifact.id()); + } + } + + swept +} + +/// Move the app's own trace stream into the retained set. +/// +/// Returns the id it was archived under, or `None` when there was nothing there +/// to archive. +/// +/// A launch that truncated this file would make cross-run comparison of the +/// app's own timings impossible whatever happened to the bundles — the +/// per-step counts live here and nowhere else — so the previous run's stream +/// is kept under the same window and budget as everything else. +pub(crate) fn archive_stream(dir: &Utf8Path, stream: &Utf8Path) -> Result, Error> { + if file_bytes(stream) == 0 { + return Ok(None); + } + + let id = format!("{STREAM_PREFIX}{}", unix_millis()); + let archived = profiles_dir(dir).join(format!("{id}.{STREAM_EXTENSION}")); + + fs::create_dir_all(profiles_dir(dir))?; + fs::rename(stream, &archived) + .map_err(|e| format!("Failed to archive {stream} as {archived}: {e}"))?; + + Ok(Some(id)) +} + +/// Every archived stream this slot holds, oldest first. +pub(crate) fn streams(dir: &Utf8Path) -> Vec { + let Ok(entries) = fs::read_dir(profiles_dir(dir)) else { + return Vec::new(); + }; + + let mut out: Vec = entries + .flatten() + .filter_map(|entry| Utf8PathBuf::from_path_buf(entry.path()).ok()) + .filter(|path| path.extension() == Some(STREAM_EXTENSION)) + .collect(); + + out.sort_by_key(stream_started_unix); + out +} + +/// When the run behind an archived stream began, read out of its name. +/// +/// An unparseable name reads as the epoch, which makes it the oldest thing in +/// the slot and the first evicted. +/// That is the right end to fail towards: a file nothing can date is a file +/// nothing can attribute either. +fn stream_started_unix(path: &Utf8PathBuf) -> u64 { + path.file_stem() + .and_then(|stem| stem.strip_prefix(STREAM_PREFIX)) + .and_then(|millis| millis.parse::().ok()) + .map_or(0, |millis| millis / 1000) +} + +/// Bundles with no record at all, from a bracket that died between creating the +/// bundle and writing its record. +fn orphaned_bundles(dir: &Utf8Path) -> Vec { + let Ok(entries) = fs::read_dir(profiles_dir(dir)) else { + return Vec::new(); + }; + + let mut swept = Vec::new(); + for entry in entries.flatten() { + let path = Utf8PathBuf::from_path_buf(entry.path()).unwrap_or_default(); + if path.extension() != Some("trace") { + continue; + } + + let id = path.file_stem().unwrap_or_default().to_owned(); + if profiles_dir(dir).join(format!("{id}.json")).exists() { + continue; + } + + if remove_dir(&path).is_ok() { + swept.push(id); + } + } + + swept +} + +/// The `xcrun xctrace record` command line for `tiers` over `scope`. +/// +/// `--instrument` rather than `--template`: on Xcode 26 a template produces a +/// bundle whose export fails with "Document Missing Template Error". +/// +/// `--no-prompt` is deliberately absent: with it a recording can abort about +/// 34ms in. +pub(crate) fn record_args(bundle: &Utf8Path, tiers: &[Tier], scope: Scope) -> Vec { + let mut args = vec!["xctrace".to_owned(), "record".to_owned()]; + + for tier in tiers { + args.push("--instrument".to_owned()); + args.push(tier.instrument().to_owned()); + } + + match scope { + Scope::Attach(pid) => { + args.push("--attach".to_owned()); + args.push(pid.to_string()); + } + Scope::System => args.push("--all-processes".to_owned()), + } + + args.push("--output".to_owned()); + args.push(bundle.to_string()); + + args +} + +/// Starting a process that outlives this one. +/// +/// A seam, because the recorder cannot be held as a `Child`: the process that +/// spawns it exits when `debug_app_profile` returns, and a later run of this +/// binary is what stops it. +pub(crate) trait Spawner { + /// Start the recorder, and return its pid once it is recording. + /// + /// Both its streams go to `log`. + fn start( + &self, + args: &[String], + log: &Utf8Path, + working_dir: &Utf8Path, + timeout: Duration, + ) -> Result; +} + +/// Production [`Spawner`]: a real `xcrun` process. +pub(crate) struct RealSpawner; + +impl Spawner for RealSpawner { + fn start( + &self, + args: &[String], + log: &Utf8Path, + working_dir: &Utf8Path, + timeout: Duration, + ) -> Result { + if let Some(parent) = log.parent() { + fs::create_dir_all(parent)?; + } + + let out = fs::File::create(log).map_err(|e| format!("Failed to create {log}: {e}"))?; + let err = out.try_clone()?; + + let mut child = Command::new(RECORDER) + .args(args) + .current_dir(working_dir) + .stdin(Stdio::null()) + .stdout(Stdio::from(out)) + .stderr(Stdio::from(err)) + .spawn() + .map_err(|e| format!("Failed to spawn `{RECORDER} {}`: {e}", args.join(" ")))?; + + let pid = child.id(); + let deadline = Instant::now() + timeout; + + loop { + let said = fs::read_to_string(log).unwrap_or_default(); + + // `try_wait` rather than a liveness check: until this process reaps + // it, a recorder that died is a zombie, and a zombie still answers + // `kill(pid, 0)`. + if let Some(status) = child.try_wait()? { + return Err(format!( + "The recorder exited with status {status} before it started recording. It \ + said:\n\n```\n{}\n```", + said.trim_end() + ) + .into()); + } + + if is_recording(&said) { + return Ok(pid); + } + + if Instant::now() >= deadline { + drop(child.kill()); + drop(child.wait()); + + return Err(format!( + "The recorder never reported that it started recording within {}s. It \ + said:\n\n```\n{}\n```", + timeout.as_secs(), + said.trim_end() + ) + .into()); + } + + thread::sleep(POLL_INTERVAL); + } + } +} + +/// Whether the recorder has said it is recording. +/// +/// Matched liberally: this is another tool's human-facing output and the +/// wording has moved between Xcode versions. +/// Every phrase here means the same thing — the recorder is live and waiting +/// to be interrupted. +fn is_recording(said: &str) -> bool { + let said = said.to_lowercase(); + + said.contains("ctrl-c") || said.contains("ctrl+c") || said.contains("starting recording") +} + +/// Whether the recorder reported a problem with what it recorded. +/// +/// The exit status is no help and is not consulted: a completed `xctrace` run +/// exits non-zero, carrying the status of what it recorded, so treating that as +/// failure throws away good traces. +/// What the recorder says on the way out is one signal; a bundle on disk is the +/// other. +pub(crate) fn run_issues(said: &str) -> bool { + said.contains(RUN_ISSUES_MARKER) +} + +/// What the recorder said about the run issues it reported. +/// +/// The marker line and the bulleted reasons under it, which is where an +/// instrument that refused its target says so. +/// The rest of the log is progress chatter. +pub(crate) fn run_issue_lines(said: &str) -> String { + said.lines() + .skip_while(|line| !line.contains(RUN_ISSUES_MARKER)) + .take_while(|line| !line.trim().is_empty()) + .collect::>() + .join("\n") +} + +/// How stopping the recorder went. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Stop { + /// Interrupted, and exited having written the bundle out. + Finalized, + + /// Already gone before it was asked to stop. + Absent, + + /// Still running when the wait ran out. + Stuck, +} + +/// Interrupt the recorder and wait for it to write the bundle out. +/// +/// `SIGINT` and nothing harsher, at any point. +/// `xctrace` finalizes the bundle on its way out, so a recorder that is killed +/// leaves one nothing can open — which makes a stuck recorder something to +/// report rather than escalate against. +pub(crate) fn stop(pid: u32, signals: &dyn Signals, timeout: Duration) -> (Stop, Duration) { + let started = Instant::now(); + + if !signals.is_alive(pid) { + return (Stop::Absent, started.elapsed()); + } + + signals.send(pid, Signal::Int); + let deadline = started + timeout; + + loop { + if !signals.is_alive(pid) { + return (Stop::Finalized, started.elapsed()); + } + + if Instant::now() >= deadline { + return (Stop::Stuck, started.elapsed()); + } + + thread::sleep(POLL_INTERVAL); + } +} + +/// An id for a new recording, and the moment it belongs to. +pub(crate) fn new_id() -> (String, u64) { + (format!("profile-{}", unix_millis()), unix_seconds()) +} + +/// Seconds since the epoch, or zero if the clock is before it. +pub(crate) fn unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +/// Milliseconds since the epoch, or zero if the clock is before it. +pub(crate) fn unix_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) +} + +/// How many bytes a file holds, or zero when it is not there. +fn file_bytes(path: &Utf8Path) -> u64 { + fs::metadata(path).map_or(0, |meta| meta.len()) +} + +/// How many bytes a directory tree holds, or zero when it is not there. +/// +/// Walked rather than asked of the directory itself, because a `.trace` +/// bundle's size is entirely in the symbol archives inside it. +fn dir_bytes(path: &Utf8Path) -> u64 { + let Ok(entries) = fs::read_dir(path) else { + return 0; + }; + + entries + .flatten() + .filter_map(|entry| { + let path = Utf8PathBuf::from_path_buf(entry.path()).ok()?; + let meta = entry.metadata().ok()?; + + Some(if meta.is_dir() { + dir_bytes(&path) + } else { + meta.len() + }) + }) + .sum() +} + +/// Remove a directory, tolerating one that is already gone. +fn remove_dir(path: &Utf8Path) -> Result<(), std::io::Error> { + match fs::remove_dir_all(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other, + } +} + +/// Remove a file, tolerating one that is already gone. +fn remove_file(path: &Utf8Path) -> Result<(), Error> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("Failed to delete {path}: {e}").into()), + } +} + +#[cfg(test)] +#[path = "capture_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/capture_tests.rs b/.config/jp/tools/src/debug_app/capture_tests.rs new file mode 100644 index 000000000..0fc545fe0 --- /dev/null +++ b/.config/jp/tools/src/debug_app/capture_tests.rs @@ -0,0 +1,585 @@ +use std::{ + fs, + sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use camino::{Utf8Path, Utf8PathBuf}; + +use super::{ + Artifact, PENDING_WINDOW, RETENTION_WINDOW, Recording, Scope, Stop, Tier, archive_stream, + enforce_limits, is_recording, parse_tiers, pending, profiles_dir, record_args, run_issue_lines, + run_issues, stop, streams, sweep, +}; +use crate::debug_app::session::{Signal, Signals}; + +/// A [`Signals`] that records what it was sent and dies on one nominated +/// signal. +/// +/// Only a fake can assert that `SIGINT` was the *only* signal sent, which is +/// the property that matters: `xctrace` writes the bundle out on its way, and a +/// recorder that is killed leaves one nothing can open. +struct Recorded { + sent: Mutex>, + alive: AtomicBool, + dies_on: Option, +} + +impl Recorded { + fn dying_on(dies_on: Signal) -> Self { + Self { + sent: Mutex::new(Vec::new()), + alive: AtomicBool::new(true), + dies_on: Some(dies_on), + } + } + + /// A recorder that never exits, whatever it is sent. + fn deaf() -> Self { + Self { + sent: Mutex::new(Vec::new()), + alive: AtomicBool::new(true), + dies_on: None, + } + } + + /// A recorder that was gone before anything was sent. + fn gone() -> Self { + Self { + sent: Mutex::new(Vec::new()), + alive: AtomicBool::new(false), + dies_on: None, + } + } + + fn sent(&self) -> Vec { + self.sent.lock().unwrap().clone() + } +} + +impl Signals for Recorded { + fn send(&self, _pid: u32, signal: Signal) { + self.sent.lock().unwrap().push(signal); + if self.dies_on == Some(signal) { + self.alive.store(false, Ordering::SeqCst); + } + } + + fn is_alive(&self, _pid: u32) -> bool { + self.alive.load(Ordering::SeqCst) + } +} + +/// Above macOS's default maximum pid, so no process can hold it. +const DEAD_PID: u32 = 4_000_000; + +fn recording(id: &str, recorder_pid: u32) -> Recording { + Recording { + id: id.to_owned(), + tiers: vec![Tier::Sampling], + scope: Scope::Attach(31657), + recorder_pid, + started_unix: super::unix_seconds(), + stopped_unix: None, + target: None, + } +} + +/// Put a recording on disk, bundle and all, as a live bracket would leave it. +fn on_disk(dir: &Utf8Path, recording: &Recording) { + fs::create_dir_all(recording.bundle(dir).join("corespace")).unwrap(); + fs::write( + recording.bundle(dir).join("corespace/data"), + "PATH=/usr/bin ANTHROPIC_API_KEY=secret", + ) + .unwrap(); + fs::write(recording.log(dir), "Ctrl-C to stop the recording\n").unwrap(); + recording.store(dir).unwrap(); +} + +fn temp() -> (camino_tempfile::Utf8TempDir, Utf8PathBuf) { + let workspace = camino_tempfile::tempdir().unwrap(); + let dir = workspace.path().to_owned(); + + (workspace, dir) +} + +/// This argument vector is the contract with `xctrace`, and every part of it +/// was arrived at the hard way, so it is pinned exactly. +#[test] +fn record_args_attach_to_one_process() { + assert_eq!( + record_args( + Utf8Path::new("/repo/tmp/profiles/p.trace"), + &[Tier::Sampling], + Scope::Attach(31657) + ), + vec![ + "xctrace", + "record", + "--instrument", + "Time Profiler", + "--attach", + "31657", + "--output", + "/repo/tmp/profiles/p.trace", + ] + ); +} + +/// The system-wide form, which is the only one that can cover an app's own +/// startup and the only one that costs minutes to read back. +#[test] +fn record_args_take_the_whole_machine_with_no_process_to_attach_to() { + assert_eq!( + record_args( + Utf8Path::new("/repo/tmp/profiles/p.trace"), + &[Tier::Sampling, Tier::Allocations], + Scope::System + ), + vec![ + "xctrace", + "record", + "--instrument", + "Time Profiler", + "--instrument", + "Allocations", + "--all-processes", + "--output", + "/repo/tmp/profiles/p.trace", + ] + ); +} + +/// A template produces a bundle whose export fails with "Document Missing +/// Template Error" on Xcode 26, and `--no-prompt` lets a recording abort about +/// 34ms in. +/// Neither absence is incidental. +#[test] +fn record_args_use_no_template_and_never_prompt_free() { + let args = record_args( + Utf8Path::new("/repo/tmp/profiles/p.trace"), + &[Tier::Sampling], + Scope::Attach(1), + ); + + assert!(!args.contains(&"--template".to_owned()), "{args:?}"); + assert!(!args.contains(&"--no-prompt".to_owned()), "{args:?}"); +} + +#[test] +fn every_recording_holds_sampling() { + assert_eq!(parse_tiers(&[]).unwrap(), vec![Tier::Sampling]); + assert_eq!(parse_tiers(&["allocations".to_owned()]).unwrap(), vec![ + Tier::Sampling, + Tier::Allocations + ]); +} + +/// Silently accepting the word would tell a caller they turned something on. +#[test] +fn asking_for_sampling_says_it_is_not_a_choice() { + let error = parse_tiers(&["sampling".to_owned()]) + .unwrap_err() + .to_string(); + + assert!( + error.starts_with("`capture` does not accept \"sampling\""), + "unexpected error: {error}" + ); +} + +#[test] +fn an_unknown_tier_is_rejected_by_name() { + let error = parse_tiers(&["leaks".to_owned()]).unwrap_err().to_string(); + + assert_eq!( + error, + "`capture` does not accept \"leaks\". The only value is \"allocations\"; sampling is \ + always recorded." + ); +} + +#[test] +fn asking_for_allocations_twice_records_it_once() { + assert_eq!( + parse_tiers(&["allocations".to_owned(), "allocations".to_owned()]).unwrap(), + vec![Tier::Sampling, Tier::Allocations] + ); +} + +#[test] +fn a_recording_names_what_it_holds() { + let sampling = recording("a", 1); + assert_eq!(sampling.describe(), "sampling"); + assert!(sampling.holds(Tier::Sampling)); + assert!(!sampling.holds(Tier::Allocations)); + + let both = Recording { + tiers: vec![Tier::Sampling, Tier::Allocations], + ..recording("b", 1) + }; + assert_eq!(both.describe(), "sampling, allocations"); + assert!(both.holds(Tier::Allocations)); +} + +/// Killing the recorder leaves a bundle nothing can open, so the escalation +/// ladder every other stop in these tools uses must not apply here. +#[test] +fn stopping_the_recorder_only_ever_interrupts_it() { + let signals = Recorded::dying_on(Signal::Int); + + let (outcome, _) = stop(4321, &signals, Duration::from_secs(2)); + + assert_eq!(outcome, Stop::Finalized); + assert_eq!(signals.sent(), vec![Signal::Int]); +} + +#[test] +fn a_recorder_that_will_not_exit_is_reported_rather_than_killed() { + let signals = Recorded::deaf(); + + let (outcome, _) = stop(4321, &signals, Duration::from_millis(200)); + + assert_eq!(outcome, Stop::Stuck); + assert_eq!(signals.sent(), vec![Signal::Int]); +} + +#[test] +fn a_recorder_already_gone_is_not_signalled() { + let signals = Recorded::gone(); + + let (outcome, _) = stop(4321, &signals, Duration::from_secs(2)); + + assert_eq!(outcome, Stop::Absent); + assert_eq!(signals.sent(), vec![]); +} + +#[test] +fn recording_is_recognized_from_what_the_recorder_prints() { + assert!(is_recording("Ctrl-C to stop the recording\n")); + assert!(is_recording( + "Starting recording with the Blank template and Time Profiler Instrument.\n" + )); + assert!(!is_recording("")); + assert!(!is_recording("Starting run...\n")); +} + +/// The exit status is useless here — a completed run carries the status of +/// what it recorded — so this string and a bundle on disk are the whole +/// signal. +#[test] +fn run_issues_are_read_out_of_the_recorders_own_words() { + assert!(run_issues( + "Recording completed.\nRun issues were detected. See the trace for details.\n" + )); + assert!(!run_issues("Recording completed.\n")); +} + +/// A bracket whose recorder is alive is the one case a sweep must leave alone. +#[test] +fn an_open_bracket_is_pending_and_survives_a_sweep() { + let (_workspace, dir) = temp(); + let open = recording("profile-1", std::process::id()); + on_disk(&dir, &open); + + assert_eq!(pending(&dir).map(|r| r.id), Some("profile-1".to_owned())); + assert_eq!(sweep(&dir), Vec::::new()); + assert!(open.bundle(&dir).exists()); +} + +/// The defect this replaced: keying on liveness made a recorder that failed on +/// its own invisible, so `stop` reported no open bracket and the sweep +/// destroyed the bundle along with the log line saying what went wrong. +/// A dead recorder is still a bracket with data in it. +#[test] +fn a_bracket_whose_recorder_died_is_still_pending() { + let (_workspace, dir) = temp(); + let failed = recording("profile-1", DEAD_PID); + on_disk(&dir, &failed); + fs::write( + failed.log(&dir), + "Ctrl-C to stop the recording\nRun issues were detected (trace is still ready to be \ + viewed):\n* [Error] Allocations cannot handle a target type of 'All Processes'\n", + ) + .unwrap(); + + assert_eq!(pending(&dir).map(|r| r.id), Some("profile-1".to_owned())); + assert_eq!(sweep(&dir), Vec::::new()); + assert!(failed.bundle(&dir).exists()); +} + +/// A bracket nobody ever closed still has to be reclaimed, or its bundle sits +/// on disk forever. +/// Age is what decides that, since liveness no longer does. +#[test] +fn a_bracket_older_than_the_window_is_swept() { + let (_workspace, dir) = temp(); + let stale = Recording { + started_unix: super::unix_seconds() - PENDING_WINDOW.as_secs() - 1, + ..recording("profile-1", std::process::id()) + }; + on_disk(&dir, &stale); + + assert_eq!(pending(&dir), None); + assert_eq!(sweep(&dir), vec!["profile-1".to_owned()]); + assert!(!stale.bundle(&dir).exists()); + assert!(!stale.log(&dir).exists()); + assert!(!stale.sidecar(&dir).exists()); +} + +/// A summary means the bracket already closed, whatever else is on disk. +#[test] +fn a_closed_bracket_is_not_pending() { + let (_workspace, dir) = temp(); + let closed = recording("profile-1", std::process::id()); + on_disk(&dir, &closed); + fs::write(closed.summary(&dir), "# profile-1\n").unwrap(); + + assert_eq!(pending(&dir), None); +} + +/// Summaries are the product and hold nothing the bundle held, so a sweep keeps +/// them. +#[test] +fn sweeping_keeps_the_summary() { + let (_workspace, dir) = temp(); + let closed = Recording { + scope: Scope::System, + ..recording("profile-1", DEAD_PID) + }; + on_disk(&dir, &closed); + fs::write(closed.summary(&dir), "# profile-1\n").unwrap(); + + sweep(&dir); + + assert!(!closed.bundle(&dir).exists()); + assert_eq!( + fs::read_to_string(closed.summary(&dir)).unwrap(), + "# profile-1\n" + ); +} + +/// The discriminator for retention is the same one that decides everything else +/// here. +/// An attach bundle holds this app's environment alone, so re-scoping it, +/// comparing it against another run, and recovering from a bad read are all +/// possible; a system-wide one holds the whole machine's and cannot be kept. +#[test] +fn a_closed_attach_recording_keeps_its_bundle_and_a_system_one_does_not() { + let (_workspace, dir) = temp(); + + let attached = recording("profile-1", DEAD_PID); + on_disk(&dir, &attached); + fs::write(attached.summary(&dir), "# profile-1\n").unwrap(); + + let system = Recording { + scope: Scope::System, + ..recording("profile-2", DEAD_PID) + }; + on_disk(&dir, &system); + fs::write(system.summary(&dir), "# profile-2\n").unwrap(); + + assert_eq!(sweep(&dir), vec!["profile-2".to_owned()]); + + assert!(attached.bundle(&dir).exists()); + assert!(attached.sidecar(&dir).exists()); + assert!(!system.bundle(&dir).exists()); + + // The system recording's record outlives its bundle, so a report can still + // say which app the surviving summary belongs to and why there is nothing to + // re-read. + assert!(system.sidecar(&dir).exists()); +} + +/// A read that failed used to leave the bracket looking open forever, which +/// blocked the next one. +/// The stop stamp is what closes it regardless. +#[test] +fn a_stopped_bracket_is_closed_even_with_no_summary_written() { + let (_workspace, dir) = temp(); + let mut stopped = recording("profile-1", DEAD_PID); + on_disk(&dir, &stopped); + + assert_eq!(pending(&dir).map(|r| r.id), Some("profile-1".to_owned())); + + stopped.close(None, &dir).unwrap(); + + assert_eq!(pending(&dir), None); + assert!(!stopped.summary(&dir).exists()); +} + +/// Retention has no meaning without something that answers "which app?", and +/// `debug_app_quit` removes the record that otherwise would. +#[test] +fn a_closed_recording_carries_what_it_was_recording() { + let (_workspace, dir) = temp(); + let mut closed = recording("profile-1", DEAD_PID); + on_disk(&dir, &closed); + + closed + .close( + Some(super::Target { + pid: 31657, + binary: "/staged/JP.app/Contents/MacOS/JP".into(), + dsym: None, + slide: Some(xct2cli::Slide::new(0x4000)), + configuration: "Debug".to_owned(), + }), + &dir, + ) + .unwrap(); + + let raw = fs::read_to_string(closed.sidecar(&dir)).unwrap(); + let loaded: Recording = serde_json::from_str(&raw).unwrap(); + let target = loaded.target.unwrap(); + + assert_eq!(target.pid, 31657); + assert_eq!(target.binary, "/staged/JP.app/Contents/MacOS/JP"); + assert_eq!(target.slide, Some(xct2cli::Slide::new(0x4000))); + assert_eq!(target.configuration, "Debug"); +} + +/// "48 hours" bounds nothing about size: a system-wide recording pulled in +/// around 450 symbol archives, and filling the disk is a demonstrated failure +/// here. +#[test] +fn the_byte_budget_evicts_the_oldest_first() { + let (_workspace, dir) = temp(); + + let oldest = Recording { + started_unix: super::unix_seconds() - 300, + ..recording("profile-old", DEAD_PID) + }; + let newest = Recording { + started_unix: super::unix_seconds(), + ..recording("profile-new", DEAD_PID) + }; + + for held in [&oldest, &newest] { + on_disk(&dir, held); + fs::write(held.bundle(&dir).join("corespace/bulk"), vec![0_u8; 4096]).unwrap(); + } + + // Room for one of the two. + let swept = enforce_limits( + &dir, + vec![ + Artifact::Bundle(newest.clone()), + Artifact::Bundle(oldest.clone()), + ], + 5_000, + ); + + assert_eq!(swept, vec!["profile-old".to_owned()]); + assert!(!oldest.bundle(&dir).exists()); + assert!(newest.bundle(&dir).exists()); +} + +/// The per-step counts live in the app's own stream and nowhere else, so a +/// launch that truncated it would make cross-run comparison impossible whatever +/// happened to the bundles. +#[test] +fn the_apps_stream_is_archived_rather_than_truncated() { + let (_workspace, dir) = temp(); + let stream = dir.join("state/trace.jsonl"); + fs::create_dir_all(stream.parent().unwrap()).unwrap(); + fs::write(&stream, "{\"timestamp\":\"x\"}\n").unwrap(); + + let id = archive_stream(&dir, &stream).unwrap().unwrap(); + + assert!(id.starts_with("trace-")); + assert!(!stream.exists()); + assert_eq!(streams(&dir).len(), 1); + assert_eq!( + fs::read_to_string(&streams(&dir)[0]).unwrap(), + "{\"timestamp\":\"x\"}\n" + ); +} + +#[test] +fn archiving_an_empty_stream_leaves_nothing_behind() { + let (_workspace, dir) = temp(); + let stream = dir.join("state/trace.jsonl"); + fs::create_dir_all(stream.parent().unwrap()).unwrap(); + fs::write(&stream, "").unwrap(); + + assert_eq!(archive_stream(&dir, &stream).unwrap(), None); + assert_eq!(streams(&dir), Vec::::new()); +} + +/// An archived stream is retained on the same terms as a bundle, so it does not +/// accumulate forever either. +#[test] +fn an_archived_stream_older_than_the_window_is_swept() { + let (_workspace, dir) = temp(); + let stale_ms = (super::unix_seconds() - RETENTION_WINDOW.as_secs() - 60) * 1000; + let path = profiles_dir(&dir).join(format!("trace-{stale_ms}.jsonl")); + fs::create_dir_all(profiles_dir(&dir)).unwrap(); + fs::write(&path, "{}\n").unwrap(); + + assert_eq!(sweep(&dir), vec![format!("trace-{stale_ms}")]); + assert!(!path.exists()); +} + +/// A bracket that died between creating its bundle and writing its record +/// leaves a bundle nothing refers to. +/// It is still recorded environments. +#[test] +fn a_bundle_with_no_record_is_swept() { + let (_workspace, dir) = temp(); + let stray = profiles_dir(&dir).join("profile-orphan.trace"); + fs::create_dir_all(&stray).unwrap(); + + assert_eq!(sweep(&dir), vec!["profile-orphan".to_owned()]); + assert!(!stray.exists()); +} + +#[test] +fn sweeping_a_slot_that_never_recorded_finds_nothing() { + let (_workspace, dir) = temp(); + + assert_eq!(sweep(&dir), Vec::::new()); + assert_eq!(pending(&dir), None); +} + +/// The whole log is the recorder's progress chatter; the reason a recording +/// failed is the marker line and the bullets under it, and that is what a +/// report has room for. +#[test] +fn run_issue_lines_keep_the_reason_and_drop_the_chatter() { + let said = "Starting recording with the Blank template and Time Profiler, Allocations \ + Instruments. Targeting All Processes.\nCtrl-C to stop the recording\nRun issues \ + were detected (trace is still ready to be viewed):\n* [Error] Allocations cannot \ + handle a target type of 'All Processes'\n\nRecording failed with errors. Saving \ + output file...\n"; + + assert_eq!( + run_issue_lines(said), + "Run issues were detected (trace is still ready to be viewed):\n* [Error] Allocations \ + cannot handle a target type of 'All Processes'" + ); + assert_eq!(run_issue_lines("Recording completed.\n"), ""); +} + +#[test] +fn a_recording_round_trips_through_its_sidecar() { + let (_workspace, dir) = temp(); + let original = Recording { + tiers: vec![Tier::Sampling, Tier::Allocations], + scope: Scope::System, + ..recording("profile-1", DEAD_PID) + }; + original.store(&dir).unwrap(); + + let raw = fs::read_to_string(original.sidecar(&dir)).unwrap(); + let loaded: Recording = serde_json::from_str(&raw).unwrap(); + + assert_eq!(loaded.id, "profile-1"); + assert_eq!(loaded.tiers, vec![Tier::Sampling, Tier::Allocations]); + assert_eq!(loaded.scope, Scope::System); + assert!(loaded.scope.is_system()); +} diff --git a/.config/jp/tools/src/debug_app/drive.rs b/.config/jp/tools/src/debug_app/drive.rs new file mode 100644 index 000000000..61aa240db --- /dev/null +++ b/.config/jp/tools/src/debug_app/drive.rs @@ -0,0 +1,515 @@ +//! `debug_app_drive` — run a step list against the running app, reporting what +//! each step changed. +//! +//! The list is [data]; this module is one harness that walks it. +//! Each step is one `jpdrive act` call, followed by a reading of the +//! accessibility tree and of the console, so a report answers three questions +//! per step: did it do the thing, what moved, and what did `AppKit` complain +//! about while it moved. +//! +//! Readings are reported as deltas against the previous one. +//! A whole tree per step in a nine-step run is most of a context window and +//! almost all of it unchanged; a delta is the handful of lines that answer +//! whether the step had the effect it was written for. +//! +//! A run stops at its first failing step. +//! The steps after it were written against a state the app never reached, so +//! running them would report on something nobody asked for. +//! +//! Each step's wall-clock window is written to [`marks`], which is what lets +//! `debug_app_profile` attribute an interval the app traced to the step that +//! caused it. +//! Nothing else records when a step ran. +//! +//! [`marks`]: super::marks +//! [data]: super::steps + +use camino::Utf8Path; +use jp_tool::Outcome; +use serde_json::Value; + +use crate::{ + Context, Error, Tool, + debug_app::{ + ambient, driver, + marks::{self, Mark}, + session::{Session, Slot}, + steps::{self, Step}, + tree, + }, + util::{ + ToolResult, + diff::text_diff, + error, + paths::{self, Shortening, shorten}, + runner::{DuctProcessRunner, ProcessRunner}, + }, +}; + +/// Lines of context around each change in a tree delta. +/// +/// Two, because a changed line in a tree means little without the elements it +/// sits between. +const DIFF_CONTEXT: usize = 2; + +/// Longest tree or delta block reported for one step. +/// +/// A switch to a different workspace replaces the whole tree, and a report of +/// that is thousands of lines saying one thing. +const MAX_BLOCK_LINES: usize = 200; + +/// Tool entrypoint. +#[allow(clippy::unused_async, reason = "awaited by the debug_app dispatcher")] +pub(crate) async fn debug_app_drive(ctx: &Context, t: &Tool) -> ToolResult { + let steps = steps::parse(&t.req::("steps")?)?; + let reads = Reads::parse(t.opt::("reads")?.as_deref())?; + let opts = tree::Options { + identifier: t.opt("identifier")?, + max_matches: t.opt("max_matches")?, + max_siblings: tree::DEFAULT_MAX_SIBLINGS, + ..tree::Options::default() + }; + + if ctx.action.is_format_arguments() { + return Ok(format_preview(&steps, &opts, reads).into()); + } + + if !cfg!(target_os = "macos") { + return error("debug_app_drive only supports macOS: it drives an AppKit application."); + } + + let dir = Session::dir(&ctx.root, &Slot::for_context(ctx)); + run(&ctx.root, &dir, &steps, &opts, reads, &DuctProcessRunner) +} + +/// Render the preview shown before execution. +fn format_preview(steps: &[Step], opts: &tree::Options, reads: Reads) -> String { + let listing = steps + .iter() + .enumerate() + .map(|(index, step)| format!("{}. {}", index + 1, step.label())) + .collect::>() + .join("\n"); + + format!( + "`debug_app_drive`\n\nWill run these steps against the app recorded in \ + `tmp/debug-app/session.json`:\n\n{listing}\n\nEach step is a `jpdrive act` call, \ + followed by a reading of the accessibility tree\nand of the console. {}\n\nChanges the \ + app's state. Stops at the first failing step.\n", + scope(opts, reads) + ) +} + +/// Whether the tree is read between steps. +/// +/// A reading is the evidence a driven run produces, so it is on by default. +/// It is also work the app does, on the thread it draws on, and a run that +/// measures the app has to be able to stop paying for it: an unscoped read +/// walks every element in the application, and the transcript publishes +/// elements in proportion to how much of a conversation is on screen. +/// Measuring a resize through reads charges the reads to the resize, and does +/// so in proportion to the very thing under study. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Reads { + /// Read after every step, and report what changed. + EveryStep, + + /// Read nothing. + /// The run reports what each step did and the console, and no tree at all. + None, +} + +impl Reads { + fn parse(value: Option<&str>) -> Result { + match value { + None | Some("every_step") => Ok(Self::EveryStep), + Some("none") => Ok(Self::None), + Some(other) => Err(format!( + "`reads` takes `every_step` or `none`, not `{other}`. Leave it out to read after \ + every step." + ) + .into()), + } + } + + const fn is_none(self) -> bool { + matches!(self, Self::None) + } +} + +/// One sentence naming how much of the tree each reading covers. +fn scope(opts: &tree::Options, reads: Reads) -> String { + if reads.is_none() { + return "The tree was not read, so no step reports what it changed. Asked for, because a \ + reading is work the app does on the thread it draws on and a run that measures \ + the app cannot afford it." + .to_owned(); + } + + match &opts.identifier { + Some(prefix) => format!("Readings cover the elements under `{prefix}`."), + None => "Readings cover the whole application.".to_owned(), + } +} + +/// Walk the list, and report every step up to and including the one that +/// stopped it. +fn run( + root: &Utf8Path, + dir: &Utf8Path, + steps: &[Step], + opts: &tree::Options, + reads: Reads, + runner: &dyn ProcessRunner, +) -> ToolResult { + let mut session = Session::resolve(dir)?; + let bin = driver::locate(root, runner)?; + + // The baseline is read but not reported: it is the whole tree, and what a + // caller is asking about is what the steps change about it. + let mut before = if reads.is_none() { + String::new() + } else { + reading(&bin, session.pid, opts, root, runner)? + }; + + // Captured before the first step, because a step that synthesizes input has + // to bring the app forward to receive it and moves the pointer to do so. Both + // belong to whoever is at the keyboard rather than to the app, so a run that + // borrows them puts them back. A run of steps that reach through the + // accessibility tree borrows nothing and captures nothing. + let borrowed = if steps.iter().any(Step::perturbs_ambient_state) { + Some(ambient::capture(&bin, root, runner)) + } else { + None + }; + + let mut body = String::new(); + let mut ran = 0; + let mut stopped = false; + let run = marks::new_run(); + let mut marked = Vec::new(); + + for (index, step) in steps.iter().enumerate() { + let began_ms = marks::now_ms(); + let acted = act(&bin, session.pid, step, root, runner)?; + let after = if reads.is_none() { + String::new() + } else { + reading(&bin, session.pid, opts, root, runner)? + }; + let out = session.stdout.delta()?; + let err = session.stderr.delta()?; + + marked.push(Mark { + run: run.clone(), + step: index + 1, + label: step.label(), + began_ms, + ended_ms: marks::now_ms(), + }); + + body.push('\n'); + body.push_str(§ion( + index + 1, + step, + &acted, + &before, + &after, + reads, + &out, + &err, + )); + before = after; + + if matches!(acted, Acted::Refused(_)) { + stopped = true; + break; + } + + ran += 1; + } + + // Restored whichever way the run went, and a failure is when it matters most: + // a run abandoned half-way has taken focus and left the pointer somewhere the + // person reading the failure did not put it. + // + // Window geometry is deliberately left as the run left it. A step that + // resized a window did the thing it was asked to, and putting it back would + // undo the effect under test. + if let Some(borrowed) = borrowed { + ambient::restore(&borrowed, &bin, root, runner); + } + + // Both written whichever way the run went. The console offsets are the only + // record of what has already been reported, and the marks are the only + // record of when the steps that did run ran. + session.store(dir)?; + marks::append(dir, &marked)?; + + let report = format!( + "{}{body}", + header( + &session, + ran, + steps.len(), + opts, + reads, + stopped, + &paths::shortenings(root) + ) + ); + + if stopped { + return error(report); + } + + Ok(Outcome::Success { + content: format!( + "{report}\nWhat each step cost the app: `debug_app_profile` with `mode: \"report\"`.\n" + ), + }) +} + +/// The line a report opens with. +/// +/// Names no process id, so two runs of the same list against the same state +/// produce the same report. +#[allow(clippy::too_many_arguments, reason = "one header names this much")] +fn header( + session: &Session, + ran: usize, + total: usize, + opts: &tree::Options, + reads: Reads, + stopped: bool, + shortenings: &[Shortening], +) -> String { + let workspace = shorten(session.workspace.as_str(), shortenings); + let mut header = match (stopped, total) { + (true, _) => format!( + "Ran {ran} of {total} steps against the app on `{workspace}`, then stopped at step {}.", + ran + 1 + ), + (false, 1) => format!("Ran the step against the app on `{workspace}`."), + (false, _) => format!("Ran all {total} steps against the app on `{workspace}`."), + }; + + let remaining = total - ran.min(total) - usize::from(stopped); + if remaining == 1 { + header.push_str(" The remaining step was not run."); + } else if remaining > 1 { + header.push_str(&format!(" The remaining {remaining} steps were not run.")); + } + + format!("{header}\n\n{}\n", scope(opts, reads)) +} + +/// Read the tree and render it. +/// +/// A prefix that matches nothing renders as one line rather than as an error. +/// A view part-way through loading holds none of the identifiers it will hold a +/// moment later, and a step list that walks through such a state is the +/// ordinary case rather than a broken one — so the delta says the elements +/// went away and the next step says they came back. +fn reading( + bin: &Utf8Path, + pid: u32, + opts: &tree::Options, + root: &Utf8Path, + runner: &dyn ProcessRunner, +) -> Result { + let Some(node) = tree::read(bin, pid, opts, root, runner)? else { + return Ok(format!( + "(nothing matched `{}`)\n", + opts.identifier.as_deref().unwrap_or_default() + )); + }; + + Ok(tree::rendered(&node, opts)) +} + +/// What the driver said about a step. +#[derive(Debug)] +enum Acted { + /// The driver ran the step and reported this. + Ran(String), + + /// Nothing was asked of the driver. + Observed, + + /// The driver refused, and said this. + Refused(String), +} + +/// Hand one step to the driver. +/// +/// A refusal comes back as [`Acted::Refused`] rather than as an error: the +/// steps before it ran, and a report of them is what says how the app got into +/// the state the failing step met. +/// `Err` is for a driver that could not be started at all. +fn act( + bin: &Utf8Path, + pid: u32, + step: &Step, + root: &Utf8Path, + runner: &dyn ProcessRunner, +) -> Result { + if step.is_snapshot() { + return Ok(Acted::Observed); + } + + let pid_arg = pid.to_string(); + let json = step.json(); + let output = runner + .run( + bin.as_str(), + &["act", "--pid", &pid_arg, "--json", &json], + root, + ) + .map_err(|e| format!("Failed to spawn {bin}: {e}"))?; + + if !output.success() { + return Ok(Acted::Refused(driver::describe_failure( + "act", + bin, + pid, + root, + runner, + &output.stdout, + &output.stderr, + ))); + } + + Ok(Acted::Ran(result_line(&output.stdout))) +} + +/// Fields reported first, so a result line reads as what happened before it +/// reads as what was checked. +const LEADING_FIELDS: [&str; 3] = ["step", "identifier", "role"]; + +/// One line describing what the driver reported about a step. +/// +/// Every field it reported is shown, rather than a chosen few: the driver owns +/// that document, and a mirror of it here would go quietly out of date. +fn result_line(stdout: &str) -> String { + let Ok(Value::Object(map)) = serde_json::from_str::(stdout) else { + return stdout.trim().to_owned(); + }; + + let mut fields: Vec = LEADING_FIELDS + .iter() + .filter_map(|key| map.get(*key).map(|value| field(key, value))) + .collect(); + + fields.extend( + map.iter() + .filter(|(key, _)| !LEADING_FIELDS.contains(&key.as_str())) + .map(|(key, value)| field(key, value)), + ); + + fields.join(" ") +} + +/// One `key=value` pair, with strings left unquoted. +fn field(key: &str, value: &Value) -> String { + match value { + Value::String(text) => format!("{key}={text}"), + other => format!("{key}={other}"), + } +} + +/// Report one step. +#[allow(clippy::too_many_arguments, reason = "one section reports this much")] +fn section( + position: usize, + step: &Step, + acted: &Acted, + before: &str, + after: &str, + reads: Reads, + out: &str, + err: &str, +) -> String { + let mut blocks = Vec::new(); + + // Nothing about the tree, because nothing was read. Saying it did not change + // would be reporting an observation that was never made. + let tree = !reads.is_none(); + + match acted { + Acted::Ran(result) => { + blocks.push(format!("{result}\n")); + if tree { + blocks.push(delta_block(before, after)); + } + } + Acted::Observed if tree => blocks.push(delta_block(before, after)), + Acted::Observed => {} + Acted::Refused(message) => { + blocks.push(format!("Failed: {message}\n")); + + // The whole reading rather than a delta. A step that failed + // usually changed nothing, and a delta of nothing does not answer + // what the app held instead. + if tree { + blocks.push(format!( + "The tree at the failure:\n\n```\n{}```\n", + cap(after) + )); + } + } + } + + for (name, content) in [("stdout", out), ("stderr", err)] { + if content.trim().is_empty() { + continue; + } + + blocks.push(format!( + "Console ({name}):\n\n```\n{}\n```\n", + content.trim_end() + )); + } + + format!("### {position}. {}\n\n{}", step.label(), blocks.join("\n")) +} + +/// What changed between two readings. +fn delta_block(before: &str, after: &str) -> String { + if before == after { + return "The tree did not change.\n".to_owned(); + } + + let diff = text_diff(before, after); + let mut unified = diff.unified_diff(); + unified.context_radius(DIFF_CONTEXT); + + format!("Tree delta:\n\n```diff\n{}```\n", cap(&unified.to_string())) +} + +/// Cap a block at [`MAX_BLOCK_LINES`], naming what was left out. +/// +/// The result always ends in a newline, so it sits inside a fence without +/// closing it on the same line. +fn cap(text: &str) -> String { + let total = text.lines().count(); + if total <= MAX_BLOCK_LINES { + return if text.ends_with('\n') { + text.to_owned() + } else { + format!("{text}\n") + }; + } + + let kept: Vec<&str> = text.lines().take(MAX_BLOCK_LINES).collect(); + format!( + "{}\n\n[{} more lines, not shown]\n", + kept.join("\n"), + total - MAX_BLOCK_LINES + ) +} + +#[cfg(test)] +#[path = "drive_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/drive_tests.rs b/.config/jp/tools/src/debug_app/drive_tests.rs new file mode 100644 index 000000000..eabb57d0d --- /dev/null +++ b/.config/jp/tools/src/debug_app/drive_tests.rs @@ -0,0 +1,459 @@ +use std::fs; + +use camino::{Utf8Path, Utf8PathBuf}; +use jp_tool::Outcome; +use serde_json::json; + +use super::{Reads, cap, header, result_line, run}; +use crate::{ + debug_app::{ + session::{Console, Session, Slot}, + steps::parse, + tree::Options, + }, + util::{ + paths::shortenings_from, + runner::{ExitCode, MockProcessRunner, ProcessOutput}, + }, +}; + +/// The app before anything is selected. +const BEFORE: &str = r#"{ + "role": "AXApplication", + "children": [{"role": "AXRow", "identifier": "sidebar.row.a", "children": []}] +}"#; + +/// The same app with the row selected. +const AFTER: &str = r#"{ + "role": "AXApplication", + "children": [ + {"role": "AXRow", "identifier": "sidebar.row.a", "focused": true, "children": []} + ] +}"#; + +/// What `jpdrive act` answers for a successful `select`, keys sorted as its +/// encoder sorts them. +const SELECTED: &str = r#"{ + "confirmed": true, + "identifier": "sidebar.row.a", + "role": "AXRow", + "step": "select" +}"#; + +/// The slot every test in this file shares, so paths are predictable. +fn dir_for(root: &Utf8Path) -> Utf8PathBuf { + Session::dir(root, &Slot::fixed("test")) +} + +/// A session pointing at this process, so it resolves as running. +fn record_session(root: &Utf8Path) -> Session { + let dir = dir_for(root); + let session = Session { + pid: std::process::id(), + bundle: Utf8Path::new("/tmp/JP.app").to_owned(), + configuration: "Debug".to_owned(), + workspace: Utf8Path::new("/repo/tmp/debug-app/workspace").to_owned(), + state_dir: dir.join("state"), + user_data_dir: dir.join("data"), + stdout: Console::new(dir.join("console.out")), + stderr: Console::new(dir.join("console.err")), + trace: Console::new(dir.join("state/trace.jsonl")), + reported_footprint_mb: None, + dsym: None, + allocation_stacks: false, + }; + + session.store(&dir).unwrap(); + fs::create_dir_all(&session.state_dir).unwrap(); + fs::write(session.pid_path(), format!("{}\n", session.pid)).unwrap(); + + session +} + +/// A file where `driver::locate` looks for the built binary. +fn fake_driver(root: &Utf8Path) -> Utf8PathBuf { + let dir = root.join("bin"); + fs::create_dir_all(&dir).unwrap(); + let bin = dir.join("jpdrive"); + fs::write(&bin, "").unwrap(); + bin +} + +#[test] +fn reports_the_result_and_the_delta_of_each_step() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let session = record_session(root); + let bin = fake_driver(root); + + // Present before the run, so the first step's console delta holds it. + fs::write(&session.stderr.path, "*** constraint complaint\n").unwrap(); + + let pid = session.pid.to_string(); + let read = ["tree", "--pid", &pid, "--max-siblings", "0"]; + let select = [ + "act", + "--pid", + &pid, + "--json", + r#"{"select":{"identifier":"sidebar.row.a"}}"#, + ]; + + let runner = MockProcessRunner::builder() + .expect("just") + .args(&["build-drive"]) + .returns_success("") + .expect("swift") + .returns_success(format!("{}\n", bin.parent().unwrap())) + // The baseline reading. + .expect(bin.as_str()) + .args(&read) + .returns_success(BEFORE) + // Step 1: select, then the reading after it. + .expect(bin.as_str()) + .args(&select) + .returns_success(SELECTED) + .expect(bin.as_str()) + .args(&read) + .returns_success(AFTER) + // Step 2: snapshot, which reads without acting. + .expect(bin.as_str()) + .args(&read) + .returns_success(AFTER); + + let steps = parse(&json!([ + {"select": {"identifier": "sidebar.row.a"}}, + {"snapshot": {}} + ])) + .unwrap(); + + let outcome = run( + root, + &dir_for(root), + &steps, + &Options::default(), + Reads::EveryStep, + &runner, + ) + .unwrap(); + + assert_eq!(outcome, Outcome::Success { + content: "Ran all 2 steps against the app on `/repo/tmp/debug-app/workspace`.\n\nReadings \ + cover the whole application.\n\n### 1. select \ + {\"identifier\":\"sidebar.row.a\"}\n\nstep=select identifier=sidebar.row.a \ + role=AXRow confirmed=true\n\nTree delta:\n\n```diff\n@@ -1,2 +1,2 @@\n \ + AXApplication\n- AXRow #sidebar.row.a\n+ AXRow #sidebar.row.a \ + [focused]\n```\n\nConsole (stderr):\n\n```\n*** constraint \ + complaint\n```\n\n### 2. snapshot\n\nThe tree did not change.\n\nWhat each step \ + cost the app: `debug_app_profile` with `mode: \"report\"`.\n" + .to_owned() + }); +} + +/// The whole point of `reads: "none"`: not one tree read is issued, so the app +/// pays nothing for being watched. +/// +/// A read is the driver's own work but the *app* answers it, on the thread it +/// draws on, and an unscoped one walks every element it publishes. +/// Against a transcript that is thousands of elements, which makes a +/// measurement of the app's own cost mostly a measurement of the reads — in +/// proportion to the content on screen, the usual thing under study. +/// +/// The mock fails the run on any command it was not told to expect, so a read +/// slipping back in is a failure here rather than a slower number somewhere +/// else. +#[test] +fn reads_none_issues_no_tree_reads_at_all() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let session = record_session(root); + let bin = fake_driver(root); + + let select = [ + "act", + "--pid", + &session.pid.to_string(), + "--json", + r#"{"select":{"identifier":"sidebar.row.a"}}"#, + ]; + + // Two build lookups and the one act. No `tree` call, in either direction. + let runner = MockProcessRunner::builder() + .expect("just") + .args(&["build-drive"]) + .returns_success("") + .expect("swift") + .returns_success(format!("{}\n", bin.parent().unwrap())) + .expect(bin.as_str()) + .args(&select) + .returns_success(SELECTED); + + let steps = parse(&json!([{"select": {"identifier": "sidebar.row.a"}}])).unwrap(); + + let outcome = run( + root, + &dir_for(root), + &steps, + &Options::default(), + Reads::None, + &runner, + ) + .unwrap(); + + let Outcome::Success { content } = outcome else { + panic!("expected success: {outcome:?}"); + }; + + assert!(content.contains("The tree was not read"), "{content}"); + assert!(!content.contains("Tree delta"), "{content}"); + assert!(!content.contains("did not change"), "{content}"); +} + +#[test] +fn reads_rejects_a_value_that_is_neither_setting() { + let error = Reads::parse(Some("sometimes")).unwrap_err().to_string(); + + assert!( + error.starts_with("`reads` takes `every_step` or `none`"), + "{error}" + ); +} + +#[test] +fn reads_defaults_to_reading_after_every_step() { + assert_eq!(Reads::parse(None).unwrap(), Reads::EveryStep); + assert_eq!(Reads::parse(Some("every_step")).unwrap(), Reads::EveryStep); + assert_eq!(Reads::parse(Some("none")).unwrap(), Reads::None); +} + +/// A scoped reading that matches nothing is a reading, not a failed run: a view +/// part-way through loading holds none of the identifiers it will hold a moment +/// later. +/// Erroring here loses the report for every step that already ran. +#[test] +fn a_reading_that_matches_nothing_is_reported_rather_than_fatal() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + record_session(root); + let bin = fake_driver(root); + + let no_match = r#"{"error": {"kind": "identifier_not_found", "message": "no element's identifier begins with sidebar.list", "hint": "drop --identifier to see what the application reports"}}"#; + + let pid = std::process::id().to_string(); + let read = [ + "tree", + "--pid", + &pid, + "--max-siblings", + "0", + "--identifier", + "sidebar.list", + ]; + + let runner = MockProcessRunner::builder() + .expect("just") + .args(&["build-drive"]) + .returns_success("") + .expect("swift") + .returns_success(format!("{}\n", bin.parent().unwrap())) + .expect(bin.as_str()) + .args(&read) + .returns_success(BEFORE) + // A `menu` step synthesizes input, so the run borrows what is in front + // and where the pointer is, and hands both back when it ends. + .expect(bin.as_str()) + .args(&["frontmost"]) + .returns_success(r#"{"bundle_id":"com.apple.Terminal"}"#) + .expect(bin.as_str()) + .args(&["pointer"]) + .returns_success(r#"{"x":10,"y":20}"#) + .expect(bin.as_str()) + .returns_success( + r#"{"step": "menu", "identifier": "File > New Window", "role": "AXMenuItem"}"#, + ) + .expect(bin.as_str()) + .args(&read) + .returns(ProcessOutput { + stdout: no_match.to_owned(), + stderr: String::new(), + status: ExitCode::from_code(1), + }) + .expect(bin.as_str()) + .args(&["frontmost", "--set", "com.apple.Terminal"]) + .returns_success("{}") + .expect(bin.as_str()) + .args(&["pointer", "--set", "10,20"]) + .returns_success("{}"); + + let steps = parse(&json!([{"menu": {"path": ["File", "New Window"]}}])).unwrap(); + let opts = Options { + identifier: Some("sidebar.list".to_owned()), + ..Options::default() + }; + + let outcome = run( + root, + &dir_for(root), + &steps, + &opts, + Reads::EveryStep, + &runner, + ) + .unwrap(); + + assert_eq!(outcome, Outcome::Success { + content: "Ran the step against the app on `/repo/tmp/debug-app/workspace`.\n\nReadings \ + cover the elements under `sidebar.list`.\n\n### 1. menu \ + {\"path\":[\"File\",\"New Window\"]}\n\nstep=menu identifier=File > New Window \ + role=AXMenuItem\n\nTree delta:\n\n```diff\n@@ -1,2 +1 @@\n-AXApplication\n- \ + AXRow #sidebar.row.a\n+(nothing matched `sidebar.list`)\n```\n\nWhat each step \ + cost the app: `debug_app_profile` with `mode: \"report\"`.\n" + .to_owned() + }); +} + +/// A wait that never resolves is the common failure, and the report has to say +/// what the tree held instead of the identifier that was waited on. +#[test] +fn stops_at_the_first_failing_step_and_shows_the_tree() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + record_session(root); + let bin = fake_driver(root); + + let refusal = r#"{"error": {"kind": "timeout", "message": "transcript.scroll did not appear within 5000ms (48 attempts over 5003ms)", "hint": "one attempt exhausted the timeout; scope the search with `under`"}}"#; + + let pid = std::process::id().to_string(); + let read = ["tree", "--pid", &pid, "--max-siblings", "0"]; + let wait = [ + "act", + "--pid", + &pid, + "--json", + r#"{"wait_for":{"identifier":"transcript.scroll"}}"#, + ]; + + let runner = MockProcessRunner::builder() + .expect("just") + .args(&["build-drive"]) + .returns_success("") + .expect("swift") + .returns_success(format!("{}\n", bin.parent().unwrap())) + .expect(bin.as_str()) + .args(&read) + .returns_success(BEFORE) + // Step 1 fails, so the reading after it is the last thing that runs. + .expect(bin.as_str()) + .args(&wait) + .returns(ProcessOutput { + stdout: refusal.to_owned(), + stderr: String::new(), + status: ExitCode::from_code(1), + }) + .expect(bin.as_str()) + .args(&read) + .returns_success(BEFORE); + + let steps = parse(&json!([ + {"wait_for": {"identifier": "transcript.scroll"}}, + {"press": {"identifier": "transcript.event.0"}}, + {"snapshot": {}} + ])) + .unwrap(); + + let outcome = run( + root, + &dir_for(root), + &steps, + &Options::default(), + Reads::EveryStep, + &runner, + ) + .unwrap(); + + let Outcome::Error { message, .. } = outcome else { + panic!("a failing step must not report success: {outcome:?}"); + }; + + assert_eq!( + message, + "Ran 0 of 3 steps against the app on `/repo/tmp/debug-app/workspace`, then stopped at \ + step 1. The remaining 2 steps were not run.\n\nReadings cover the whole \ + application.\n\n### 1. wait_for {\"identifier\":\"transcript.scroll\"}\n\nFailed: \ + `jpdrive act` failed (timeout): transcript.scroll did not appear within 5000ms (48 \ + attempts over 5003ms)\n\nHint: one attempt exhausted the timeout; scope the search with \ + `under`\n\nThe tree at the failure:\n\n```\nAXApplication\n AXRow #sidebar.row.a\n```\n" + ); +} + +/// The driver owns the result document, so every field it reports is shown +/// rather than a chosen few. +#[test] +fn a_result_line_shows_every_field_the_driver_reported() { + let line = result_line( + r#"{"committed": false, "confirmed": true, "identifier": "search.field", "role": "AXTextField", "step": "type"}"#, + ); + + assert_eq!( + line, + "step=type identifier=search.field role=AXTextField committed=false confirmed=true" + ); +} + +/// A driver that answered something unparseable is quoted rather than dropped. +#[test] +fn a_result_line_falls_back_to_the_raw_output() { + assert_eq!(result_line(" not json\n"), "not json"); +} + +#[test] +fn a_capped_block_names_what_it_left_out() { + let text = (1..=250) + .map(|n| format!("line {n}")) + .collect::>() + .join("\n"); + + let capped = cap(&text); + + assert!(capped.starts_with("line 1\nline 2\n"), "{capped}"); + assert!( + capped.ends_with("line 200\n\n[50 more lines, not shown]\n"), + "{capped}" + ); +} + +/// A block that fits is passed through, but always ends in a newline so it does +/// not close its fence on the same line. +#[test] +fn a_short_block_gains_only_a_trailing_newline() { + assert_eq!(cap("one\ntwo"), "one\ntwo\n"); + assert_eq!(cap("one\ntwo\n"), "one\ntwo\n"); +} + +/// The header names no process id: a relaunch changes it, and two runs of the +/// same list against the same state should produce the same report. +#[test] +fn the_header_names_the_scope_and_no_process_id() { + let workspace = camino_tempfile::tempdir().unwrap(); + let session = record_session(workspace.path()); + let opts = Options { + identifier: Some("sidebar.".to_owned()), + ..Options::default() + }; + + // Relative to the repository: a report is meant to be pasteable into an + // issue, and an absolute path here says whose machine produced it. + let shortenings = shortenings_from(Utf8Path::new("/repo"), Some("/Users/jean"), None, None); + + assert_eq!( + header(&session, 9, 9, &opts, Reads::EveryStep, false, &shortenings), + "Ran all 9 steps against the app on `tmp/debug-app/workspace`.\n\nReadings cover the \ + elements under `sidebar.`.\n" + ); + + assert_eq!( + header(&session, 3, 9, &opts, Reads::EveryStep, true, &shortenings), + "Ran 3 of 9 steps against the app on `tmp/debug-app/workspace`, then stopped at step 4. \ + The remaining 5 steps were not run.\n\nReadings cover the elements under `sidebar.`.\n" + ); +} diff --git a/.config/jp/tools/src/debug_app/driver.rs b/.config/jp/tools/src/debug_app/driver.rs new file mode 100644 index 000000000..e66b6bc58 --- /dev/null +++ b/.config/jp/tools/src/debug_app/driver.rs @@ -0,0 +1,192 @@ +//! Talking to `jpdrive`, the accessibility driver the app tools read and act +//! through. +//! +//! The driver speaks JSON on stdout for both results and errors, distinguished +//! by exit status. +//! This module owns finding it, and turning a failed run into something a +//! caller can act on; interpreting a successful one belongs to whichever tool +//! asked. + +use camino::{Utf8Path, Utf8PathBuf}; +use serde::Deserialize; + +use crate::{ + Error, + util::runner::{ProcessOutput, ProcessRunner}, +}; + +/// The `jpdrive` package, relative to the repository root. +const PACKAGE: &str = "apps/macos/Tools/jpdrive"; + +/// Build the driver and return its binary. +pub(crate) fn locate(root: &Utf8Path, runner: &dyn ProcessRunner) -> Result { + let build = runner + .run("just", &["build-drive"], root) + .map_err(|e| format!("Failed to spawn `just build-drive`: {e}"))?; + if !build.success() { + return Err(format!("`just build-drive` failed:\n\n```\n{}\n```", said(&build)).into()); + } + + // The binary sits under an architecture-specific directory, so SwiftPM is + // asked where rather than guessed at. + let path = runner + .run( + "swift", + &[ + "build", + "--package-path", + PACKAGE, + "-c", + "release", + "--show-bin-path", + ], + root, + ) + .map_err(|e| format!("Failed to spawn `swift build --show-bin-path`: {e}"))?; + if !path.success() { + return Err(format!( + "`swift build --show-bin-path` failed:\n\n```\n{}\n```", + said(&path) + ) + .into()); + } + + let bin = Utf8PathBuf::from(path.stdout.trim()).join("jpdrive"); + if !bin.is_file() { + return Err(format!("`just build-drive` left no driver at {bin}.").into()); + } + + Ok(bin) +} + +/// Everything a failed command said, on whichever stream it said it. +/// +/// `just` reports only that a recipe exited non-zero; the compiler diagnostics +/// that explain why are on the recipe's own stdout. +/// Quoting stderr alone leaves a build failure reading `recipe failed with exit +/// code 1` and nothing else. +fn said(output: &ProcessOutput) -> String { + let mut said = String::new(); + for stream in [output.stdout.trim_end(), output.stderr.trim_end()] { + if stream.is_empty() { + continue; + } + + if !said.is_empty() { + said.push('\n'); + } + said.push_str(stream); + } + + if said.is_empty() { + return "(it said nothing on either stream)".to_owned(); + } + + said +} + +/// The driver's error document. +#[derive(Debug, Deserialize)] +struct ErrorDocument { + error: DriverError, +} + +#[derive(Debug, Deserialize)] +struct DriverError { + kind: String, + message: String, + hint: Option, +} + +/// Turn a failed driver run into something actionable. +/// +/// `command` names the subcommand that failed, so a report says which one. +/// +/// Falls back to the raw streams when the document does not parse, because a +/// driver that failed before it could write JSON is exactly when the raw output +/// is worth reading. +pub(crate) fn failure(command: &str, stdout: &str, stderr: &str) -> String { + let Ok(document) = serde_json::from_str::(stdout) else { + return format!( + "`jpdrive {command}` failed and reported nothing \ + parseable.\n\nstdout:\n\n```\n{}\n```\n\nstderr:\n\n```\n{}\n```", + stdout.trim_end(), + stderr.trim_end() + ); + }; + + let mut message = format!( + "`jpdrive {command}` failed ({}): {}", + document.error.kind, document.error.message + ); + if let Some(hint) = document.error.hint { + message.push_str(&format!("\n\nHint: {hint}")); + } + + message +} + +/// The kind the driver named, when it answered a parseable error document. +pub(crate) fn kind(stdout: &str) -> Option { + serde_json::from_str::(stdout) + .ok() + .map(|document| document.error.kind) +} + +/// Whether the driver refused for lack of an Accessibility grant. +pub(crate) fn is_not_permitted(stdout: &str) -> bool { + kind(stdout).as_deref() == Some("not_permitted") +} + +/// Ask the driver what it can see, after it has refused to act. +/// +/// Returns `None` when the diagnostic itself fails: a broken diagnostic must +/// not replace the error it was meant to explain. +/// +/// Quoted verbatim rather than summarised. +/// Whether a grant given to a terminal reaches a tool that terminal started is +/// undocumented by Apple and has to be measured, so the ancestor chain and the +/// probe are evidence for a reader, not a verdict to restate. +pub(crate) fn diagnose_permission( + driver: &Utf8Path, + pid: u32, + root: &Utf8Path, + runner: &dyn ProcessRunner, +) -> Option { + let report = runner + .run( + driver.as_str(), + &["doctor", "--pid", &pid.to_string()], + root, + ) + .ok()?; + + Some(format!( + "\n\n`jpdrive doctor` reports:\n\n```json\n{}\n```", + report.stdout.trim_end() + )) +} + +/// Run `command`, and turn a refusal into a message carrying the diagnosis. +pub(crate) fn describe_failure( + command: &str, + driver: &Utf8Path, + pid: u32, + root: &Utf8Path, + runner: &dyn ProcessRunner, + stdout: &str, + stderr: &str, +) -> String { + let mut message = failure(command, stdout, stderr); + if is_not_permitted(stdout) + && let Some(diagnosis) = diagnose_permission(driver, pid, root, runner) + { + message.push_str(&diagnosis); + } + + message +} + +#[cfg(test)] +#[path = "driver_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/driver_tests.rs b/.config/jp/tools/src/debug_app/driver_tests.rs new file mode 100644 index 000000000..cf3e9b7c0 --- /dev/null +++ b/.config/jp/tools/src/debug_app/driver_tests.rs @@ -0,0 +1,84 @@ +use super::{failure, is_not_permitted, said}; +use crate::util::runner::{ExitCode, ProcessOutput}; + +fn output(stdout: &str, stderr: &str) -> ProcessOutput { + ProcessOutput { + stdout: stdout.to_owned(), + stderr: stderr.to_owned(), + status: ExitCode::from_code(1), + } +} + +/// `just` puts only "recipe failed" on stderr and leaves the compiler +/// diagnostics on the recipe's stdout, so quoting one stream loses the reason. +#[test] +fn a_failure_quotes_both_streams() { + assert_eq!( + said(&output( + "error: cannot find 'Slot'\n", + "recipe failed with exit code 1\n" + )), + "error: cannot find 'Slot'\nrecipe failed with exit code 1" + ); +} + +#[test] +fn a_failure_quotes_whichever_stream_spoke() { + assert_eq!(said(&output("only stdout\n", "")), "only stdout"); + assert_eq!(said(&output("", "only stderr\n")), "only stderr"); +} + +/// A command that failed silently still has to report something, or the message +/// reads as an empty code block. +#[test] +fn a_silent_failure_says_so() { + assert_eq!( + said(&output("", " \n")), + "(it said nothing on either stream)" + ); +} + +#[test] +fn failure_reports_the_command_the_kind_the_message_and_the_hint() { + let stdout = r#"{"error": {"kind": "not_permitted", "message": "not trusted to read another application's accessibility tree", "hint": "grant Accessibility to the terminal"}}"#; + + assert_eq!( + failure("tree", stdout, ""), + "`jpdrive tree` failed (not_permitted): not trusted to read another application's \ + accessibility tree\n\nHint: grant Accessibility to the terminal" + ); +} + +#[test] +fn failure_omits_an_absent_hint() { + let stdout = r#"{"error": {"kind": "app_not_running", "message": "no process is running under pid 4321"}}"#; + + assert_eq!( + failure("act", stdout, ""), + "`jpdrive act` failed (app_not_running): no process is running under pid 4321" + ); +} + +/// A driver that died before writing JSON is exactly when the raw streams +/// matter. +#[test] +fn failure_falls_back_to_the_raw_streams() { + assert_eq!( + failure("tree", "", "dyld: Library not loaded\n"), + "`jpdrive tree` failed and reported nothing \ + parseable.\n\nstdout:\n\n```\n\n```\n\nstderr:\n\n```\ndyld: Library not loaded\n```" + ); +} + +/// Only a refused read is worth a follow-up probe. +/// Any other failure would run the diagnostic for nothing and bury the real +/// error under it. +#[test] +fn only_a_refusal_asks_for_a_diagnosis() { + let refused = r#"{"error": {"kind": "not_permitted", "message": "not trusted"}}"#; + let missing = r#"{"error": {"kind": "app_not_running", "message": "no process"}}"#; + + assert!(is_not_permitted(refused)); + assert!(!is_not_permitted(missing)); + assert!(!is_not_permitted("dyld: Library not loaded")); +} diff --git a/.config/jp/tools/src/debug_app/hotspots.rs b/.config/jp/tools/src/debug_app/hotspots.rs new file mode 100644 index 000000000..727099630 --- /dev/null +++ b/.config/jp/tools/src/debug_app/hotspots.rs @@ -0,0 +1,301 @@ +//! Reading a recorded bundle down to a summary worth keeping. +//! +//! A `.trace` embeds the environment of every process it recorded, and `xctrace +//! export --toc` prints it. +//! A system-wide recording therefore holds whatever every process on the +//! machine had exported, and the caller destroys that bundle as soon as this +//! returns — see [`Recording::retire`]. +//! +//! Extraction goes through `xct2cli`, which strips `` from +//! everything it parses and keeps its raw-XML accessors crate-private, so there +//! is no path by which a recorded environment reaches a report. +//! +//! Never commit a bundle or attach one to a bug report. +//! +//! [`Recording::retire`]: super::capture::Recording::retire + +use std::fs; + +use camino::{Utf8Path, Utf8PathBuf}; +use xct2cli::{ + Pid, TraceBundle, + analysis::{HotspotReport, HotspotsBuilder, SlideMode}, +}; + +use crate::{ + Error, + debug_app::capture::{Recording, Target, Tier}, + util::paths::{Shortening, shorten}, +}; + +/// How many of the busiest program counters to symbolicate. +/// +/// Far more than the report shows, because most of what an app is doing on-CPU +/// is inside the dyld shared cache, which a trace carries no symbols for. +/// Only after symbolication is it known which counters belong to code we can +/// name, and taking the busiest 25 before that yields a table of bare +/// addresses. +const EXAMINED_PCS: usize = 500; + +/// How many named frames a summary shows. +pub(crate) const TOP_FRAMES: usize = 25; + +/// A recording, reduced to what can safely be kept. +pub(crate) struct Summary { + pub path: Utf8PathBuf, + pub content: String, +} + +/// Read the bundle and write a summary beside it. +/// +/// Does not delete the bundle. +/// The caller does that unconditionally, so that a failure here still leaves +/// nothing behind. +pub(crate) fn summarize( + recording: &Recording, + dir: &Utf8Path, + target: Option<&Target>, + shortenings: &[Shortening], +) -> Result { + let extract = extract(recording, dir, target)?; + let content = render(recording, &extract, target, shortenings); + let path = recording.summary(dir); + + fs::write(&path, &content).map_err(|e| format!("Failed to write {path}: {e}"))?; + + Ok(Summary { path, content }) +} + +/// The parts of the bundle worth keeping. +struct Extract { + /// Absent when there was no app to attribute samples to. + hotspots: Option, + + /// How long the recording ran, in seconds, as the bundle reports it. + duration: Option, + + /// Why the recording ended. + end_reason: Option, + + /// The instrument tables the bundle holds. + tables: Vec, +} + +fn extract( + recording: &Recording, + dir: &Utf8Path, + target: Option<&Target>, +) -> Result { + let bundle = TraceBundle::open(recording.bundle(dir).as_std_path())?; + + let toc = bundle.toc()?; + let run = toc.first_run(); + let summary = run.and_then(|r| r.info.summary.as_ref()); + let mut tables: Vec = run + .map(|r| r.tables.iter().map(|t| t.schema.clone()).collect()) + .unwrap_or_default(); + tables.sort_unstable(); + tables.dedup(); + + let hotspots = match target { + None => None, + Some(target) => Some(read_hotspots(&bundle, target, None, EXAMINED_PCS)?), + }; + + Ok(Extract { + hotspots, + duration: summary.and_then(|s| s.duration.clone()), + end_reason: summary.and_then(|s| s.end_reason.clone()), + tables, + }) +} + +/// Render the summary that replaces the bundle. +fn render( + recording: &Recording, + extract: &Extract, + target: Option<&Target>, + shortenings: &[Shortening], +) -> String { + let mut out = format!("# `{}`\n\n", recording.id); + + out.push_str(&format!("- recorded: {}\n", recording.describe())); + out.push_str(&format!( + "- scope: {}\n", + if recording.scope.is_system() { + "every process on the machine" + } else { + "the app alone" + } + )); + + match target { + Some(target) => out.push_str(&format!( + "- app: pid {}, `{}`\n", + target.pid, target.configuration + )), + None => out.push_str("- app: none was launched into this recording\n"), + } + + if let Some(duration) = &extract.duration { + out.push_str(&format!("- recording: {duration}s\n")); + } + if let Some(reason) = &extract.end_reason { + out.push_str(&format!("- ended: {reason}\n")); + } + if !extract.tables.is_empty() { + out.push_str(&format!("- tables: {}\n", extract.tables.join(", "))); + } + + out.push_str("\n## Time profile\n\n"); + match &extract.hotspots { + None => out.push_str( + "Nothing to attribute: the bracket recorded the machine but no app was launched into \ + it.\n", + ), + Some(hotspots) => out.push_str(&render_hotspots(hotspots, shortenings, TOP_FRAMES)), + } + + if recording.holds(Tier::Allocations) { + out.push_str( + "\n## Allocations\n\nRecorded, and readable only while the bundle existed. Timings \ + above ran under `MallocStackLogging`, which costs 2x to 10x and not evenly — compare \ + them against another allocations recording, never against a sampling one.\n", + ); + } + + if recording.keeps_bundle() { + out.push_str( + "\nThe `.trace` bundle is kept, so `debug_app_profile` with `mode: \"report\"` can \ + ask it further questions.\n", + ); + } else { + out.push_str( + "\nThe `.trace` bundle this came from was deleted: recorded system-wide, it embeds \ + the environment of every process on the machine.\n", + ); + } + + out +} + +/// Where a symbolicator gets the load address to subtract. +/// +/// The address the app reported for itself when there is one. +/// Recovering it from the trace's image-load events is the fallback, and it +/// only works for a recording that was already running when those images were +/// mapped — which an attached bracket never is. +/// Against an attached recording the fallback silently resolves to a zero +/// slide, and every frame comes back as a bare address or, more confusingly, as +/// whatever symbol happens to live at that offset. +/// +/// Every read of a bundle goes through this, so a new one cannot resolve frames +/// against a different address than the others do. +pub(crate) fn slide_mode(target: &Target) -> SlideMode { + match target.slide { + Some(slide) => SlideMode::Manual(slide), + None => SlideMode::Auto, + } +} + +/// Read the busiest program counters out of an open bundle. +/// +/// `filter` keeps only frames whose resolved name holds it, which costs +/// symbolicating every examined counter rather than only the ones kept. +pub(crate) fn read_hotspots( + bundle: &TraceBundle, + target: &Target, + filter: Option, + examined: usize, +) -> Result { + HotspotsBuilder::new(bundle) + .pid(Pid::new(i64::from(target.pid))) + .binary(Some(target.binary.as_std_path().to_owned())) + .dsym(target.dsym.as_ref().map(|p| p.as_std_path().to_owned())) + .slide(slide_mode(target)) + .filter(filter) + .top(examined) + .run() + .map_err(Into::into) +} + +/// Render the frames that could be named, and account for those that could not. +pub(crate) fn render_hotspots( + hotspots: &HotspotReport, + shortenings: &[Shortening], + top: usize, +) -> String { + let total = hotspots.total_samples; + if total == 0 { + return "No samples landed in the app. Either it spent the bracket blocked — which is the \ + normal state for an app nobody is driving — or the bracket closed before it did \ + any work.\n" + .to_owned(); + } + + let examined = hotspots.top_pcs.len(); + let named: Vec<_> = hotspots + .top_pcs + .iter() + .filter(|h| h.function.is_some()) + .collect(); + + let mut out = format!("{total} samples landed in the app.\n\n"); + + if named.is_empty() { + out.push_str(&format!( + "None of the {examined} busiest program counters resolved to a symbol, so all of this \ + time is in code the trace carries no symbols for — the dyld shared cache, most \ + likely. A run under a bracket that covers real work should look different; if it \ + does not, the slide is wrong.\n" + )); + + return out; + } + + // Without this a reader cannot tell whether the frames below are all there + // were, or the visible corner of a much longer tail. + if named.len() > top { + out.push_str(&format!( + "Showing the {top} busiest of {} named frames.\n\n", + named.len() + )); + } + + out.push_str("| samples | share | function | site |\n| ---: | ---: | --- | --- |\n"); + for hotspot in named.iter().take(top) { + #[allow(clippy::cast_precision_loss, reason = "display only")] + let share = (hotspot.samples as f64 / total as f64) * 100.0; + + let function = hotspot.function.clone().unwrap_or_default(); + + // DWARF holds these as absolute paths on the machine that built the + // binary, and a summary is meant to be pasteable into an issue. + let site = match (&hotspot.file, hotspot.line) { + (Some(file), Some(line)) => format!("{}:{line}", shorten(file, shortenings)), + (Some(file), None) => shorten(file, shortenings), + _ => String::new(), + }; + + out.push_str(&format!( + "| {} | {share:.1}% | `{}` | {site} |\n", + hotspot.samples, + function.replace('|', "\\|") + )); + } + + let unnamed = examined - named.len(); + if unnamed > 0 { + out.push_str(&format!( + "\n{unnamed} of the {examined} busiest program counters had no symbol in the app's \ + binary. That is system code in the dyld shared cache, which a trace carries no \ + symbols for.\n" + )); + } + + out +} + +#[cfg(test)] +#[path = "hotspots_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/hotspots_tests.rs b/.config/jp/tools/src/debug_app/hotspots_tests.rs new file mode 100644 index 000000000..2d8f958c4 --- /dev/null +++ b/.config/jp/tools/src/debug_app/hotspots_tests.rs @@ -0,0 +1,269 @@ +use camino::Utf8Path; +use xct2cli::{ + RuntimePc, + analysis::{Hotspot, SlideMode}, +}; + +use super::{Extract, render, slide_mode}; +use crate::{ + debug_app::capture::{Recording, Scope, Target, Tier}, + util::paths::{Shortening, shortenings_from}, +}; + +/// A wrong load address does not fail, it lies: frames come back as bare +/// addresses, or as whatever symbol happens to sit at that offset, and the +/// table looks plausible either way. +/// So a recording's own reported address is what every read of its bundle uses, +/// and only a build that reports none falls back to recovering one from the +/// trace. +#[test] +fn a_reported_load_address_is_used_rather_than_recovered() { + let reported = Target { + slide: Some(xct2cli::Slide::new(0x4000)), + ..target() + }; + + assert!(matches!( + slide_mode(&reported), + SlideMode::Manual(slide) if slide == xct2cli::Slide::new(0x4000) + )); + + // Only a build that reports nothing falls back, and only that case can be + // wrong about where the images were mapped. + assert!(matches!(slide_mode(&target()), SlideMode::Auto)); +} + +/// A machine whose layout matches the paths the fixtures use. +fn shortenings() -> Vec { + shortenings_from( + Utf8Path::new("/Users/jean/Projects/jp"), + Some("/Users/jean"), + None, + None, + ) +} + +fn recording(tiers: Vec, scope: Scope) -> Recording { + Recording { + id: "profile-1785748475000".to_owned(), + tiers, + scope, + recorder_pid: 4321, + started_unix: 1_785_748_475, + stopped_unix: Some(1_785_748_480), + target: Some(target()), + } +} + +fn target() -> Target { + Target { + pid: 31657, + binary: "/derived/JP.app/Contents/MacOS/JP".into(), + dsym: None, + slide: None, + configuration: "Debug".to_owned(), + } +} + +/// A frame the app's own dSYM could name. +fn named(samples: u64, function: &str, line: u32) -> Hotspot { + Hotspot { + pc: RuntimePc::new(0x1_0000_4a20), + samples, + fmt: None, + function: Some(function.to_owned()), + file: Some( + "/Users/jean/Projects/jp/apps/macos/Sources/ConversationHistoryView.swift".to_owned(), + ), + line: Some(line), + } +} + +/// A frame in the dyld shared cache, which a trace carries no symbols for. +fn unnamed(samples: u64) -> Hotspot { + Hotspot { + pc: RuntimePc::new(0x1_9f1c_e3cc), + samples, + fmt: None, + function: None, + file: None, + line: None, + } +} + +fn extract(top: Vec, total_samples: u64) -> Extract { + let mut hotspots = xct2cli::analysis::HotspotReport::empty(10_000_000); + hotspots.total_samples = total_samples; + hotspots.top_pcs = top; + + Extract { + hotspots: Some(hotspots), + duration: Some("37.087810".to_owned()), + end_reason: Some("User pressed Stop".to_owned()), + tables: vec!["time-sample".to_owned()], + } +} + +#[test] +fn renders_the_run_and_its_named_frames() { + let summary = render( + &recording(vec![Tier::Sampling], Scope::Attach(31657)), + &extract( + vec![ + named(200, "JP.ConversationHistoryView.body.getter", 88), + named(50, "JP.WorkspaceModel.load()", 141), + ], + 400, + ), + Some(&target()), + &shortenings(), + ); + + assert_eq!( + summary, + "# `profile-1785748475000`\n\n- recorded: sampling\n- scope: the app alone\n- app: pid \ + 31657, `Debug`\n- recording: 37.087810s\n- ended: User pressed Stop\n- tables: \ + time-sample\n\n## Time profile\n\n400 samples landed in the app.\n\n| samples | share | \ + function | site |\n| ---: | ---: | --- | --- |\n| 200 | 50.0% | \ + `JP.ConversationHistoryView.body.getter` | \ + apps/macos/Sources/ConversationHistoryView.swift:88 |\n| 50 | 12.5% | \ + `JP.WorkspaceModel.load()` | apps/macos/Sources/ConversationHistoryView.swift:141 \ + |\n\nThe `.trace` bundle is kept, so `debug_app_profile` with `mode: \"report\"` can ask \ + it further questions.\n" + ); +} + +/// The defect this selection exists for: taking the busiest counters without +/// regard to whether they resolved produced a table of 29 bare addresses out of +/// 30, because an app's on-CPU time is mostly inside the dyld shared cache. +#[test] +fn keeps_the_named_frames_and_accounts_for_the_rest() { + let mut top = vec![unnamed(23), unnamed(23), unnamed(17)]; + top.push(named(3, "JP.WorkspaceModel.load()", 141)); + + let summary = render( + &recording(vec![Tier::Sampling], Scope::Attach(31657)), + &extract(top, 808), + Some(&target()), + &shortenings(), + ); + + assert!( + summary.contains("| 3 | 0.4% | `JP.WorkspaceModel.load()` |"), + "unexpected summary: {summary}" + ); + assert!( + !summary.contains("0x"), + "a bare address reached the table: {summary}" + ); + assert!( + summary.contains( + "3 of the 4 busiest program counters had no symbol in the app's binary. That is \ + system code in the dyld shared cache, which a trace carries no symbols for." + ), + "unexpected summary: {summary}" + ); +} + +/// Every frame unresolved means either an idle bracket or a wrong slide, and a +/// silent empty table looks like neither. +#[test] +fn says_so_when_nothing_resolved_at_all() { + let summary = render( + &recording(vec![Tier::Sampling], Scope::Attach(31657)), + &extract(vec![unnamed(23), unnamed(17)], 808), + Some(&target()), + &shortenings(), + ); + + assert!( + summary.contains("None of the 2 busiest program counters resolved to a symbol"), + "unexpected summary: {summary}" + ); + assert!(summary.contains("the slide is wrong"), "{summary}"); + assert!(!summary.contains("| samples |"), "{summary}"); +} + +#[test] +fn says_why_an_empty_profile_is_empty() { + let summary = render( + &recording(vec![Tier::Sampling], Scope::Attach(31657)), + &extract(vec![], 0), + Some(&target()), + &shortenings(), + ); + + assert!( + summary.contains("No samples landed in the app."), + "unexpected summary: {summary}" + ); +} + +/// A bracket opened before any launch, into which nothing was ever launched. +#[test] +fn reports_a_system_recording_with_no_app() { + let summary = render( + &recording(vec![Tier::Sampling], Scope::System), + &Extract { + hotspots: None, + duration: Some("12.0".to_owned()), + end_reason: None, + tables: Vec::new(), + }, + None, + &shortenings(), + ); + + assert!( + summary.contains("- scope: every process on the machine"), + "{summary}" + ); + assert!( + summary.contains("- app: none was launched into this recording"), + "{summary}" + ); + assert!( + summary.contains("no app was launched into it"), + "unexpected summary: {summary}" + ); +} + +/// Numbers taken under `MallocStackLogging` are not comparable with numbers +/// taken without it, and nothing in the table itself says so. +#[test] +fn an_allocations_recording_warns_that_its_timings_are_distorted() { + let summary = render( + &recording( + vec![Tier::Sampling, Tier::Allocations], + Scope::Attach(31657), + ), + &extract(vec![named(1, "JP.main", 1)], 1), + Some(&target()), + &shortenings(), + ); + + assert!( + summary.contains("- recorded: sampling, allocations"), + "{summary}" + ); + assert!(summary.contains("## Allocations"), "{summary}"); + assert!( + summary.contains("costs 2x to 10x and not evenly"), + "unexpected summary: {summary}" + ); +} + +#[test] +fn a_pipe_in_a_symbol_does_not_break_the_table() { + let summary = render( + &recording(vec![Tier::Sampling], Scope::Attach(31657)), + &extract(vec![named(1, "closure #1 (A|B) in JP.main", 1)], 1), + Some(&target()), + &shortenings(), + ); + + assert!( + summary.contains(r"`closure #1 (A\|B) in JP.main`"), + "unexpected summary: {summary}" + ); +} diff --git a/.config/jp/tools/src/debug_app/launch.rs b/.config/jp/tools/src/debug_app/launch.rs new file mode 100644 index 000000000..0f31517d5 --- /dev/null +++ b/.config/jp/tools/src/debug_app/launch.rs @@ -0,0 +1,787 @@ +//! `debug_app_launch` — build the macOS app and start a driveable instance. +//! +//! Launches through `LaunchServices` (`open -n`) rather than by executing the +//! binary inside the bundle, because some `AppKit` behaviour depends on the app +//! being registered normally. +//! `open` also injects the environment and redirects both output streams to +//! files, so the console stays readable without a pipe this process has to keep +//! draining. +//! +//! Isolation is by environment, all of it verified rather than assumed: +//! +//! - `JP_DEBUG_STATE_DIR` moves the app's recent-workspace list into a file +//! under `tmp/debug-app/state/`, and is how the app reports its own pid. +//! Without it the app writes the recents list it shares with the system, +//! which no harness can read back or restore. +//! - `JP_USER_DATA_DIR` moves the user-local conversation store. +//! - `JP_WORKSPACE` names the workspace to open. +//! It is consulted only when the recents list is empty, which a fresh state +//! directory guarantees. +//! +//! Window state saved by `@SceneStorage` is keyed by bundle identifier rather +//! than by environment, so the bundle that runs is a copy carrying this slot's +//! own identifier. +//! That is what keeps a driven run out of the developer's own window state, and +//! two agents out of each other's. + +use std::{ + fs, thread, + time::{Duration, Instant}, +}; + +use camino::{Utf8Path, Utf8PathBuf}; +use jp_tool::Outcome; +use serde::Deserialize; + +use crate::{ + Context, Error, Tool, + debug_app::{ + capture, + session::{Console, Session, Slot, state_dir, trace_path}, + }, + util::{ + ToolResult, error, + paths::{self, Shortening, shorten}, + runner::{DuctProcessRunner, ProcessRunner}, + }, +}; + +/// Xcode configuration built when the caller names none. +const DEFAULT_CONFIGURATION: &str = "Debug"; + +/// How long to wait for the app to report its pid before giving up. +/// +/// Generous: this covers `LaunchServices` starting the process, `AppKit` +/// finishing its own setup, and the app reaching its `init`. +const PID_TIMEOUT: Duration = Duration::from_secs(30); + +/// Poll interval while waiting for the pid file. +const POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// The entitlements a staged bundle is signed with. +/// +/// `get-task-allow` is what lets a profiler attach, and the staged copy would +/// otherwise have none: the build's ad-hoc signature carries it, and re-signing +/// after the identifier rewrite replaces that signature wholesale. +/// +/// Only this one, because the app declares no entitlements file and is not +/// sandboxed, so this is the whole of what a Debug build gets. +/// Applied whatever the configuration, because a staged bundle exists to be +/// driven and profiled and never leaves `tmp/`. +const ENTITLEMENTS: &str = r#" + + + + com.apple.security.get-task-allow + + + +"#; + +/// The workspace ID written into a scratch workspace. +/// +/// Fixed rather than random: the scratch workspace is reused across runs, and a +/// stable ID means a stable user-local store rather than a new one each launch. +const SCRATCH_WORKSPACE_ID: &str = "probe"; + +/// Which appearance the app is told to draw in. +/// +/// Given as an argument rather than an environment variable because that is the +/// only lever there is: `AppKit` reads `AppleInterfaceStyle` through +/// `NSUserDefaults`, whose argument domain outranks every other, and a launch +/// argument is how a caller writes to it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Appearance { + Light, + Dark, +} + +impl Appearance { + /// What `AppleInterfaceStyle` is set to. + /// + /// `AppKit` tests this against `Dark` and treats anything else as light, so + /// naming the light case explicitly forces light on a machine set to dark + /// rather than merely leaving the choice alone. + const fn style(self) -> &'static str { + match self { + Self::Light => "Light", + Self::Dark => "Dark", + } + } + + fn parse(value: &str) -> Result { + match value { + "light" => Ok(Self::Light), + "dark" => Ok(Self::Dark), + other => Err(format!( + "`appearance` takes `light` or `dark`, not `{other}`. Leave it out to follow the \ + system." + ) + .into()), + } + } +} + +/// What to launch, and with what environment. +#[derive(Debug, Clone)] +pub(crate) struct LaunchSpec { + pub bundle: Utf8PathBuf, + pub workspace: Utf8PathBuf, + pub state_dir: Utf8PathBuf, + pub user_data_dir: Utf8PathBuf, + pub stdout: Utf8PathBuf, + pub stderr: Utf8PathBuf, + + /// Where the app writes its own trace, inside the state directory. + pub trace: Utf8PathBuf, + + /// Whether the app is told to keep a stack for every allocation. + /// + /// Decided here because libmalloc reads `MallocStackLogging` at process + /// start, so an app that was not launched for it can never report + /// allocation stacks, whatever a profile bracket later asks for. + pub allocation_stacks: bool, + + /// Which appearance to draw in, or `None` to follow the system. + pub appearance: Option, + + /// Whether to make the app ignore the window state `AppKit` saved for it. + /// + /// Window frames and `@SceneStorage` are kept by `AppKit` under the bundle + /// identifier, in the user's home directory, which no environment variable + /// moves and no state directory holds. + /// Without this a run restores whatever the last one left, including a + /// frame that cannot be recovered from: a window restored to no size is + /// absent from the window server's list entirely, so it can be neither read + /// nor captured nor resized. + pub ignore_saved_windows: bool, +} + +/// Tool entrypoint. +#[allow(clippy::unused_async, reason = "awaited by the debug_app dispatcher")] +pub(crate) async fn debug_app_launch(ctx: &Context, t: &Tool) -> ToolResult { + let workspace: Option = t.opt("workspace")?; + let configuration = t + .opt::("configuration")? + .unwrap_or_else(|| DEFAULT_CONFIGURATION.to_owned()); + let fresh = t.opt::("fresh")?.unwrap_or(true); + let allocation_stacks = t.opt::("allocation_stacks")?.unwrap_or(false); + let appearance = t + .opt::("appearance")? + .map(|value| Appearance::parse(&value)) + .transpose()?; + + if ctx.action.is_format_arguments() { + return Ok(format_preview( + workspace.as_deref(), + &configuration, + fresh, + allocation_stacks, + appearance, + ) + .into()); + } + + if !cfg!(target_os = "macos") { + return error( + "debug_app_launch only supports macOS: it drives an AppKit application through \ + LaunchServices.", + ); + } + + let slot = Slot::for_context(ctx); + run( + &ctx.root, + &slot, + workspace.as_deref(), + &configuration, + fresh, + allocation_stacks, + appearance, + &DuctProcessRunner, + ) +} + +/// Render the preview shown before execution. +fn format_preview( + workspace: Option<&str>, + configuration: &str, + fresh: bool, + allocation_stacks: bool, + appearance: Option, +) -> String { + let malloc = if allocation_stacks { + "\nAlso passes `--env MallocStackLogging=1`, so the app keeps a stack for \ + every\nallocation and `debug_app_profile` can record allocations against it. That costs \ + 2x\nto 10x and not evenly, so every timing in the session becomes comparable only \ + with\nanother such session.\n" + } else { + "" + }; + + let style = appearance.map_or_else(String::new, |appearance| { + format!( + "\nAlso passes `--args -AppleInterfaceStyle {}`, so the app draws in {} appearance \ + whatever\nthe machine is set to.\n", + appearance.style(), + appearance.style().to_lowercase() + ) + }); + + let workspace = workspace.unwrap_or("tmp/debug-app/workspace (scratch, created if missing)"); + let state = if fresh { + "- State directory is emptied first, so the app opens the workspace named above.\n" + } else { + "- State directory is kept, so the app restores whatever it had open last and\n ignores \ + the workspace named above.\n" + }; + + format!( + "`debug_app_launch`\n\nWill execute:\n\n```sh\njust build-app {configuration}\nopen -n -g \ + -a \\\n --env JP_DEBUG_STATE_DIR=tmp/debug-app/state \\\n --env \ + JP_USER_DATA_DIR=tmp/debug-app/data \\\n --env JP_WORKSPACE={workspace} \\\n --stdout \ + tmp/debug-app/console.out \\\n --stderr tmp/debug-app/console.err\n```\n\nLeaves a \ + running GUI application behind, addressed by later `debug_app_*` calls and\nstopped with \ + `debug_app_quit`.\n\nIsolation:\n\n- Recent-workspace list: a file under \ + `tmp/debug-app//state/`, not the list the app\n shares with the system.\n- \ + Conversation store: `tmp/debug-app//data/`.\n- Window state (`@SceneStorage`): a \ + bundle copy carrying this slot's own\n identifier.\n{state}\nRecords \ + `tmp/debug-app/session.json` and returns whatever the app wrote to its\nconsole while \ + starting up.\n{malloc}{style}" + ) +} + +/// Build, launch, and record the session. +#[allow(clippy::too_many_arguments, reason = "a launch has this many knobs")] +fn run( + root: &Utf8Path, + slot: &Slot, + workspace: Option<&str>, + configuration: &str, + fresh: bool, + allocation_stacks: bool, + appearance: Option, + runner: &dyn ProcessRunner, +) -> ToolResult { + let dir = Session::dir(root, slot); + + // A second instance would take the pid file and the state directory from the + // first, leaving neither addressable. Refuse rather than pick one. + if let Some(existing) = Session::load(&dir)? + && existing.is_running() + { + return error(format!( + "An app session is already running as pid {}, launched against {}. Stop it with \ + `debug_app_quit` before launching another.", + existing.pid, + shorten(existing.workspace.as_str(), &paths::shortenings(root)) + )); + } + + let state_dir = state_dir(&dir); + let user_data_dir = dir.join("data"); + + // Before the state directory is prepared, which with `fresh` removes it + // wholesale and would take the previous run's stream with it. + let archived = capture::archive_stream(&dir, &trace_path(&state_dir))?; + + prepare_state_dir(&state_dir, fresh)?; + fs::create_dir_all(&user_data_dir)?; + + let workspace = if let Some(path) = workspace { + resolve_workspace(root, path)? + } else { + let scratch = dir.join("workspace"); + create_scratch_workspace(&scratch)?; + scratch + }; + + let build = runner + .run("just", &["build-app", configuration], root) + .map_err(|e| format!("Failed to spawn `just build-app`: {e}"))?; + if !build.success() { + return error(format!( + "`just build-app {configuration}` failed:\n\n```\n{}\n```", + build.stderr.trim_end() + )); + } + + let built = locate_bundle(root, configuration, runner)?; + let bundle = stage_bundle(&built, &dir, slot, root, runner)?; + + let trace = trace_path(&state_dir); + let spec = LaunchSpec { + bundle, + workspace, + state_dir, + user_data_dir, + stdout: dir.join("console.out"), + stderr: dir.join("console.err"), + trace, + allocation_stacks, + appearance, + // A fresh run is fresh in every respect it can be, and the window frame is + // one of them. Kept when `fresh` is false, because observing what the app + // restores is the whole point of that. + ignore_saved_windows: fresh, + }; + + // Truncate before launching so the first delta holds this run's output and + // not the previous run's. The trace stream was archived rather than + // truncated, so what is created here is an empty file for this run alone. + fs::write(&spec.stdout, "")?; + fs::write(&spec.stderr, "")?; + fs::write(&spec.trace, "")?; + + let args = open_args(&spec); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let launch = runner + .run("open", &arg_refs, root) + .map_err(|e| format!("Failed to spawn `open`: {e}"))?; + if !launch.success() { + return error(format!( + "`open` refused to launch {}:\n\n```\n{}\n```", + spec.bundle, + launch.stderr.trim_end() + )); + } + + let pid = wait_for_pid(&spec.state_dir, PID_TIMEOUT)?; + + let mut session = Session { + pid, + bundle: spec.bundle, + configuration: configuration.to_owned(), + workspace: spec.workspace, + state_dir: spec.state_dir, + user_data_dir: spec.user_data_dir, + stdout: Console::new(spec.stdout), + stderr: Console::new(spec.stderr), + trace: Console::new(spec.trace), + reported_footprint_mb: None, + dsym: locate_dsym(&built), + allocation_stacks, + }; + + let out = session.stdout.delta()?; + let err = session.stderr.delta()?; + session.store(&dir)?; + + Ok(Outcome::Success { + content: report( + &paths::shortenings(root), + &dir, + &session, + &out, + &err, + archived.as_deref(), + ), + }) +} + +/// The dSYM matching `built`, when the build produced one. +/// +/// Xcode writes it beside the bundle under the bundle's own name. +/// A configuration built without `dwarf-with-dsym` has none, and symbolication +/// then falls back to whatever the binary itself carries. +fn locate_dsym(built: &Utf8Path) -> Option { + let name = built.file_name()?; + let stem = built.file_stem()?.to_owned(); + let path = built + .with_file_name(format!("{name}.dSYM")) + .join("Contents/Resources/DWARF") + .join(stem); + + path.is_file().then_some(path) +} + +/// The `open(1)` command line for `spec`. +/// +/// `-g` keeps the app off the foreground. +/// A driven launch that stole keyboard focus would interrupt whatever the +/// caller was typing, and reading an accessibility tree does not need the app +/// frontmost. +/// +/// `MallocStackLogging` rides along when a profile bracket recording +/// allocations is already open, because libmalloc reads it at process start and +/// there is no later moment at which it can be switched on. +fn open_args(spec: &LaunchSpec) -> Vec { + let mut args = vec![ + "-n".to_owned(), + "-g".to_owned(), + "-a".to_owned(), + spec.bundle.to_string(), + "--env".to_owned(), + format!("JP_DEBUG_STATE_DIR={}", spec.state_dir), + "--env".to_owned(), + format!("JP_USER_DATA_DIR={}", spec.user_data_dir), + "--env".to_owned(), + format!("JP_WORKSPACE={}", spec.workspace), + ]; + + if spec.allocation_stacks { + args.push("--env".to_owned()); + args.push("MallocStackLogging=1".to_owned()); + } + + args.extend([ + "--stdout".to_owned(), + spec.stdout.to_string(), + "--stderr".to_owned(), + spec.stderr.to_string(), + ]); + + // Last, and last for a reason: everything after `--args` is handed to the app + // rather than read by `open`. One `--args`, because a second would be passed + // through as an argument rather than starting a new list. + let mut app_args: Vec = Vec::new(); + + if spec.ignore_saved_windows { + app_args.extend(["-ApplePersistenceIgnoreState".to_owned(), "YES".to_owned()]); + } + + if let Some(appearance) = spec.appearance { + app_args.extend([ + "-AppleInterfaceStyle".to_owned(), + appearance.style().to_owned(), + ]); + } + + if !app_args.is_empty() { + args.push("--args".to_owned()); + args.extend(app_args); + } + + args +} + +/// 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. +fn locate_bundle( + root: &Utf8Path, + configuration: &str, + runner: &dyn ProcessRunner, +) -> Result { + let output = runner + .run( + "xcodebuild", + &[ + "-project", + "apps/macos/JP.xcodeproj", + "-scheme", + "JP", + "-configuration", + configuration, + "-showBuildSettings", + "-json", + ], + root, + ) + .map_err(|e| format!("Failed to spawn `xcodebuild`: {e}"))?; + + if !output.success() { + return Err(format!( + "`xcodebuild -showBuildSettings` failed: {}", + output.stderr.trim_end() + ) + .into()); + } + + let bundle = bundle_path(&output.stdout)?; + if !bundle.is_dir() { + return Err(format!( + "Xcode reports the app at {bundle}, but nothing is there. Try `just build-app \ + {configuration}` by hand." + ) + .into()); + } + + Ok(bundle) +} + +/// The bundle identifier a driven instance runs under. +/// +/// Derived from the slot, because everything macOS keys by bundle identifier is +/// shared by every process using it: the recent-workspace list, and the window +/// state `@SceneStorage` writes. +/// An environment variable reaches neither, so the identifier is the only +/// lever, and without it two agents would restore each other's windows. +fn bundle_identifier(slot: &str) -> String { + format!("computer.jp.jean-pierre.drive.{slot}") +} + +/// Copy the built bundle and give it this slot's identifier. +/// +/// Copied on every launch rather than kept: the copy is the thing that runs, so +/// a stale one would silently drive the previous build. +/// +/// Re-signing is not optional. +/// Rewriting `Info.plist` invalidates the signature the build produced, and +/// macOS refuses to launch a bundle whose signature does not match its +/// contents. +/// It is also what makes [`ENTITLEMENTS`] necessary: the new signature carries +/// only what it is given. +fn stage_bundle( + built: &Utf8Path, + dir: &Utf8Path, + slot: &Slot, + root: &Utf8Path, + runner: &dyn ProcessRunner, +) -> Result { + let staged = dir.join("JP.app"); + if staged.exists() { + fs::remove_dir_all(&staged).map_err(|e| format!("Failed to clear {staged}: {e}"))?; + } + + run_step(runner, root, "cp", &["-R", built.as_str(), staged.as_str()])?; + + let identifier = bundle_identifier(slot.as_str()); + let plist = staged.join("Contents/Info.plist"); + run_step(runner, root, "plutil", &[ + "-replace", + "CFBundleIdentifier", + "-string", + &identifier, + plist.as_str(), + ])?; + + let entitlements = dir.join("entitlements.plist"); + fs::write(&entitlements, ENTITLEMENTS) + .map_err(|e| format!("Failed to write {entitlements}: {e}"))?; + + run_step(runner, root, "codesign", &[ + "--force", + "--sign", + "-", + "--entitlements", + entitlements.as_str(), + staged.as_str(), + ])?; + + Ok(staged) +} + +/// Run one staging command, failing with what it said. +fn run_step( + runner: &dyn ProcessRunner, + root: &Utf8Path, + program: &str, + args: &[&str], +) -> Result<(), Error> { + let output = runner + .run(program, args, root) + .map_err(|e| format!("Failed to spawn `{program}`: {e}"))?; + + if !output.success() { + return Err(format!( + "`{program} {}` failed: {}", + args.join(" "), + output.stderr.trim_end() + ) + .into()); + } + + Ok(()) +} + +/// One target's build settings, as `xcodebuild -showBuildSettings -json` +/// reports them. +#[derive(Debug, Deserialize)] +struct TargetSettings { + target: String, + #[serde(rename = "buildSettings")] + settings: BuildSettings, +} + +#[derive(Debug, Deserialize)] +struct BuildSettings { + #[serde(rename = "BUILT_PRODUCTS_DIR")] + products_dir: String, + #[serde(rename = "FULL_PRODUCT_NAME")] + product_name: String, +} + +/// The `JP` target's bundle path, from `xcodebuild -showBuildSettings -json`. +/// +/// Parsing starts at the first `[` because xcodebuild prepends free-form +/// notices about ambiguous destinations, and which stream those land on varies +/// by version. +fn bundle_path(raw: &str) -> Result { + let json = raw + .find('[') + .map(|start| &raw[start..]) + .ok_or("`xcodebuild -showBuildSettings -json` produced no JSON")?; + + let targets: Vec = serde_json::from_str(json) + .map_err(|e| format!("Failed to parse xcodebuild build settings: {e}"))?; + + let target = targets + .into_iter() + .find(|t| t.target == "JP") + .ok_or("xcodebuild reported no settings for the `JP` target")?; + + Ok(Utf8PathBuf::from(format!( + "{}/{}", + target.settings.products_dir, target.settings.product_name + ))) +} + +/// Wait for the app to write its pid. +/// +/// `open` reports no process id of its own, and matching on the executable path +/// cannot tell this instance from one the developer left running, so the app +/// reporting its own pid is the only unambiguous answer. +fn wait_for_pid(state_dir: &Utf8Path, timeout: Duration) -> Result { + let path = state_dir.join("pid"); + let deadline = Instant::now() + timeout; + + loop { + if let Ok(raw) = fs::read_to_string(&path) + && let Ok(pid) = raw.trim().parse::() + { + return Ok(pid); + } + + if Instant::now() >= deadline { + return Err(format!( + "The app never reported its pid at {path} within {}s. It may have failed to \ + start, or it may be a build without `JP_DEBUG_STATE_DIR` support.", + timeout.as_secs() + ) + .into()); + } + + thread::sleep(POLL_INTERVAL); + } +} + +/// Resolve a caller-supplied workspace path against the repository root. +/// +/// The result is canonical. +/// The app stores whatever path it is given and keys windows by it, so a `.` or +/// a `..` left in would have the same workspace open twice under two spellings. +fn resolve_workspace(root: &Utf8Path, path: &str) -> Result { + let candidate = if Utf8Path::new(path).is_absolute() { + Utf8PathBuf::from(path) + } else { + root.join(path) + }; + + if !candidate.is_dir() { + return Err(format!("No directory at {candidate}.").into()); + } + + candidate + .canonicalize_utf8() + .map_err(|e| format!("Failed to resolve {candidate}: {e}").into()) +} + +/// Create a workspace with an empty store at `path`, if it is not there +/// already. +fn create_scratch_workspace(path: &Utf8Path) -> Result<(), Error> { + let store = path.join(".jp"); + fs::create_dir_all(&store)?; + + let id = store.join(".id"); + if id.exists() { + return Ok(()); + } + + // `Id::load` reads the last line and rejects anything that is not five + // characters of `[0-9a-z]`. + fs::write( + &id, + format!("DO NOT EDIT THIS FILE! IT IS AUTO-GENERATED BY JP.\n{SCRATCH_WORKSPACE_ID}\n"), + ) + .map_err(|e| format!("Failed to write {id}: {e}").into()) +} + +/// Ready the state directory for a launch. +/// +/// The stale pid always goes, whichever mode this runs in: the app writes that +/// file once at startup, so leaving the previous run's value there would let +/// [`wait_for_pid`] return a dead process immediately and report success. +/// +/// `fresh` decides the rest. +/// Emptying the directory leaves the app with no recents list, which is what +/// makes it consult `JP_WORKSPACE` — the app prefers its most recent workspace +/// over the environment. +/// Keeping the directory is what makes a quit-and-relaunch pair test +/// restoration, since the app then reopens what it had before and ignores +/// `JP_WORKSPACE`. +fn prepare_state_dir(dir: &Utf8Path, fresh: bool) -> Result<(), Error> { + if fresh && dir.exists() { + fs::remove_dir_all(dir).map_err(|e| format!("Failed to clear {dir}: {e}"))?; + } + + fs::create_dir_all(dir).map_err(|e| format!("Failed to create {dir}: {e}"))?; + + let pid = dir.join("pid"); + match fs::remove_file(&pid) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("Failed to remove the stale pid file at {pid}: {e}").into()), + } +} + +/// Render the launch report. +fn report( + shortenings: &[Shortening], + dir: &Utf8Path, + session: &Session, + out: &str, + err: &str, + archived: Option<&str>, +) -> String { + let mut report = format!( + "Launched the macOS app as pid {}.\n\n- bundle: `{}`\n- configuration: `{}`\n- workspace: \ + `{}`\n- state: `{}`\n- user data: `{}`\n- session: `{}`\n", + session.pid, + shorten(session.bundle.as_str(), shortenings), + session.configuration, + shorten(session.workspace.as_str(), shortenings), + shorten(session.state_dir.as_str(), shortenings), + shorten(session.user_data_dir.as_str(), shortenings), + shorten(Session::path(dir).as_str(), shortenings), + ); + + if let Some(id) = archived { + report.push_str(&format!( + "\nThe previous run's traced intervals were archived as `{id}`, so \ + `debug_app_profile` with `mode: \"report\"` can still read them and compare this run \ + against them.\n" + )); + } + + if session.allocation_stacks { + report.push_str( + "\nThe app keeps a stack for every allocation, so `debug_app_profile` can record \ + allocations against it. **Timings in this session are distorted**: \ + `MallocStackLogging` costs 2x to 10x and not evenly, so allocation-heavy paths slow \ + disproportionately.\n", + ); + } + + for (name, content) in [("stdout", out), ("stderr", err)] { + if content.trim().is_empty() { + continue; + } + + report.push_str(&format!( + "\nConsole ({name}):\n\n```\n{}\n```\n", + content.trim_end() + )); + } + + if out.trim().is_empty() && err.trim().is_empty() { + report.push_str("\nThe app wrote nothing to either console stream while starting.\n"); + } + + report +} + +#[cfg(test)] +#[path = "launch_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/launch_tests.rs b/.config/jp/tools/src/debug_app/launch_tests.rs new file mode 100644 index 000000000..d720b3693 --- /dev/null +++ b/.config/jp/tools/src/debug_app/launch_tests.rs @@ -0,0 +1,506 @@ +use std::{fs, time::Duration}; + +use camino::Utf8Path; + +use super::{ + Appearance, bundle_identifier, bundle_path, create_scratch_workspace, format_preview, + open_args, prepare_state_dir, resolve_workspace, run, stage_bundle, wait_for_pid, +}; +use crate::{ + debug_app::{ + launch::LaunchSpec, + session::{Console, Session, Slot}, + }, + util::runner::MockProcessRunner, +}; + +/// What `xcodebuild -showBuildSettings -json` reports, trimmed to the two keys +/// that matter and the noise it puts in front of them. +const BUILD_SETTINGS: &str = r#"--- xcodebuild: WARNING: Using the first of multiple matching destinations: +{ platform:macOS, arch:arm64, id:00006021-0004606A3E30C01E, name:My Mac } +[ + { + "action": "build", + "target": "JPTests", + "buildSettings": { + "BUILT_PRODUCTS_DIR": "/derived/Build/Products/Debug", + "FULL_PRODUCT_NAME": "JPTests.xctest" + } + }, + { + "action": "build", + "target": "JP", + "buildSettings": { + "BUILT_PRODUCTS_DIR": "/derived/Build/Products/Debug", + "FULL_PRODUCT_NAME": "JP.app" + } + } +]"#; + +fn spec(allocation_stacks: bool) -> LaunchSpec { + styled_spec(allocation_stacks, None) +} + +fn styled_spec(allocation_stacks: bool, appearance: Option) -> LaunchSpec { + windowed_spec(allocation_stacks, appearance, false) +} + +fn windowed_spec( + allocation_stacks: bool, + appearance: Option, + ignore_saved_windows: bool, +) -> LaunchSpec { + LaunchSpec { + bundle: "/derived/JP.app".into(), + workspace: "/repo/tmp/debug-app/workspace".into(), + state_dir: "/repo/tmp/debug-app/state".into(), + user_data_dir: "/repo/tmp/debug-app/data".into(), + stdout: "/repo/tmp/debug-app/console.out".into(), + stderr: "/repo/tmp/debug-app/console.err".into(), + trace: "/repo/tmp/debug-app/state/trace.jsonl".into(), + allocation_stacks, + appearance, + ignore_saved_windows, + } +} + +#[test] +fn bundle_path_finds_the_app_target_past_the_leading_notice() { + assert_eq!( + bundle_path(BUILD_SETTINGS).unwrap(), + "/derived/Build/Products/Debug/JP.app" + ); +} + +#[test] +fn bundle_path_rejects_settings_without_the_app_target() { + let settings = r#"[{"target": "JPTests", "buildSettings": {"BUILT_PRODUCTS_DIR": "/d", "FULL_PRODUCT_NAME": "JPTests.xctest"}}]"#; + + assert_eq!( + bundle_path(settings).unwrap_err().to_string(), + "xcodebuild reported no settings for the `JP` target" + ); +} + +#[test] +fn bundle_path_rejects_output_holding_no_json() { + assert_eq!( + bundle_path("xcodebuild: error: nothing to see") + .unwrap_err() + .to_string(), + "`xcodebuild -showBuildSettings -json` produced no JSON" + ); +} + +/// The whole isolation story is in this argument vector, so it is pinned +/// exactly. +/// `-g` is part of it: without it every launch steals keyboard focus from +/// whatever the caller was typing into. +#[test] +fn open_args_carry_the_environment_and_both_redirects() { + assert_eq!(open_args(&spec(false)), vec![ + "-n", + "-g", + "-a", + "/derived/JP.app", + "--env", + "JP_DEBUG_STATE_DIR=/repo/tmp/debug-app/state", + "--env", + "JP_USER_DATA_DIR=/repo/tmp/debug-app/data", + "--env", + "JP_WORKSPACE=/repo/tmp/debug-app/workspace", + "--stdout", + "/repo/tmp/debug-app/console.out", + "--stderr", + "/repo/tmp/debug-app/console.err", + ]); +} + +/// libmalloc reads `MallocStackLogging` at process start, so this is the only +/// moment allocation attribution can be turned on at all. +/// It reaches the app through the same `open` call, and only when the caller +/// asked for it. +#[test] +fn open_args_pass_malloc_stack_logging_only_when_asked() { + assert_eq!(open_args(&spec(true)), vec![ + "-n", + "-g", + "-a", + "/derived/JP.app", + "--env", + "JP_DEBUG_STATE_DIR=/repo/tmp/debug-app/state", + "--env", + "JP_USER_DATA_DIR=/repo/tmp/debug-app/data", + "--env", + "JP_WORKSPACE=/repo/tmp/debug-app/workspace", + "--env", + "MallocStackLogging=1", + "--stdout", + "/repo/tmp/debug-app/console.out", + "--stderr", + "/repo/tmp/debug-app/console.err", + ]); + + assert!(!open_args(&spec(false)).contains(&"MallocStackLogging=1".to_owned())); +} + +/// The staged copy is re-signed after its identifier is rewritten, and a new +/// signature carries only what it is given. +/// Without `--entitlements` the copy loses `get-task-allow`, which nothing +/// notices until a profile bracket tries to attach and finds it cannot. +#[test] +fn staging_signs_the_copy_so_a_profiler_can_attach() { + let workspace = camino_tempfile::tempdir().unwrap(); + let dir = workspace.path(); + let staged = dir.join("JP.app"); + let entitlements = dir.join("entitlements.plist"); + + let runner = MockProcessRunner::builder() + .expect("cp") + .returns_success("") + .expect("plutil") + .returns_success("") + .expect("codesign") + .args(&[ + "--force", + "--sign", + "-", + "--entitlements", + entitlements.as_str(), + staged.as_str(), + ]) + .returns_success(""); + + stage_bundle( + Utf8Path::new("/derived/JP.app"), + dir, + &Slot::fixed("test"), + dir, + &runner, + ) + .unwrap(); + + let written = fs::read_to_string(&entitlements).unwrap(); + assert!( + written.contains("com.apple.security.get-task-allow"), + "unexpected entitlements: {written}" + ); +} + +#[test] +fn wait_for_pid_reads_the_pid_the_app_reported() { + let workspace = camino_tempfile::tempdir().unwrap(); + fs::write(workspace.path().join("pid"), "88223\n").unwrap(); + + assert_eq!( + wait_for_pid(workspace.path(), Duration::from_millis(50)).unwrap(), + 88223 + ); +} + +#[test] +fn wait_for_pid_gives_up_when_the_app_never_reports() { + let workspace = camino_tempfile::tempdir().unwrap(); + + let error = wait_for_pid(workspace.path(), Duration::from_millis(50)) + .unwrap_err() + .to_string(); + + assert!( + error.starts_with(&format!( + "The app never reported its pid at {}", + workspace.path().join("pid") + )), + "unexpected error: {error}" + ); +} + +#[test] +fn wait_for_pid_ignores_a_file_that_is_not_a_pid() { + let workspace = camino_tempfile::tempdir().unwrap(); + fs::write(workspace.path().join("pid"), "not a number\n").unwrap(); + + assert!(wait_for_pid(workspace.path(), Duration::from_millis(50)).is_err()); +} + +#[test] +fn create_scratch_workspace_writes_a_store_jp_accepts() { + let workspace = camino_tempfile::tempdir().unwrap(); + let path = workspace.path().join("scratch"); + + create_scratch_workspace(&path).unwrap(); + + let id = fs::read_to_string(path.join(".jp/.id")).unwrap(); + assert_eq!( + id, + "DO NOT EDIT THIS FILE! IT IS AUTO-GENERATED BY JP.\nprobe\n" + ); +} + +/// Reusing the scratch workspace keeps its ID, so the app's user-local store +/// for it survives across runs. +#[test] +fn create_scratch_workspace_keeps_an_existing_id() { + let workspace = camino_tempfile::tempdir().unwrap(); + let path = workspace.path().join("scratch"); + fs::create_dir_all(path.join(".jp")).unwrap(); + fs::write(path.join(".jp/.id"), "kept\n").unwrap(); + + create_scratch_workspace(&path).unwrap(); + + assert_eq!(fs::read_to_string(path.join(".jp/.id")).unwrap(), "kept\n"); +} + +/// `.` is not a path component, so an uncanonicalized `/.` both renders +/// as an empty string in the report and reaches the app as a second spelling of +/// a workspace it already keys windows by. +#[test] +fn resolve_workspace_canonicalizes_a_relative_path() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let expected = root.canonicalize_utf8().unwrap(); + + assert_eq!(resolve_workspace(root, ".").unwrap(), expected); +} + +#[test] +fn resolve_workspace_rejects_a_path_with_no_directory() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + + let error = resolve_workspace(root, "nope").unwrap_err().to_string(); + + assert_eq!(error, format!("No directory at {}.", root.join("nope"))); +} + +/// Two agents on one identifier would restore each other's windows, and both +/// would write the recent-workspace list the developer's own app reads. +#[test] +fn each_slot_runs_under_its_own_bundle_identifier() { + assert_eq!( + bundle_identifier("default"), + "computer.jp.jean-pierre.drive.default" + ); + assert_ne!(bundle_identifier("one"), bundle_identifier("two")); + + // Never the identifier the developer's own build runs under. + assert_ne!(bundle_identifier("default"), "computer.jp.jean-pierre"); +} + +#[test] +fn prepare_state_dir_when_fresh_drops_the_recents_list() { + let workspace = camino_tempfile::tempdir().unwrap(); + let state = workspace.path().join("state"); + fs::create_dir_all(&state).unwrap(); + fs::write(state.join("recents.json"), "[]").unwrap(); + fs::write(state.join("pid"), "1\n").unwrap(); + + prepare_state_dir(&state, true).unwrap(); + + assert!(!state.join("recents.json").exists()); + assert!(!state.join("pid").exists()); +} + +/// A relaunch that tests restoration keeps the list, but never the pid: the app +/// writes that once at startup, so a stale one would be read as this run's. +#[test] +fn prepare_state_dir_when_not_fresh_keeps_the_recents_list_but_not_the_pid() { + let workspace = camino_tempfile::tempdir().unwrap(); + let state = workspace.path().join("state"); + fs::create_dir_all(&state).unwrap(); + fs::write(state.join("recents.json"), "[\"/a\"]").unwrap(); + fs::write(state.join("pid"), "1\n").unwrap(); + + prepare_state_dir(&state, false).unwrap(); + + assert_eq!( + fs::read_to_string(state.join("recents.json")).unwrap(), + "[\"/a\"]" + ); + assert!(!state.join("pid").exists()); +} + +#[test] +fn prepare_state_dir_creates_a_missing_directory() { + let workspace = camino_tempfile::tempdir().unwrap(); + let state = workspace.path().join("state"); + + prepare_state_dir(&state, true).unwrap(); + + assert!(state.is_dir()); +} + +/// Two instances would fight over one pid file and one state directory, leaving +/// neither addressable. +/// The refusal has to come before the build, which is the expensive part. +#[test] +fn run_refuses_while_an_app_is_already_running() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let slot = Slot::fixed("test"); + let dir = Session::dir(root, &slot); + let pid = std::process::id(); + + let session = Session { + pid, + bundle: "/derived/JP.app".into(), + configuration: "Debug".to_owned(), + workspace: root.join("workspace"), + state_dir: dir.join("state"), + user_data_dir: dir.join("data"), + stdout: Console::new(dir.join("console.out")), + stderr: Console::new(dir.join("console.err")), + trace: Console::new(dir.join("state/trace.jsonl")), + reported_footprint_mb: None, + dsym: None, + allocation_stacks: false, + }; + session.store(&dir).unwrap(); + fs::create_dir_all(&session.state_dir).unwrap(); + fs::write(session.pid_path(), format!("{pid}\n")).unwrap(); + + // Fails the test on any command at all, so a refusal that still built the app + // cannot pass. + let runner = MockProcessRunner::never_called(); + let outcome = run(root, &slot, None, "Debug", true, false, None, &runner).unwrap(); + + let jp_tool::Outcome::Error { message, .. } = outcome else { + panic!("expected an error outcome, got: {outcome:?}"); + }; + // Named relative to the repository, like every other path a report prints. + assert_eq!( + message, + format!( + "An app session is already running as pid {pid}, launched against workspace. Stop it \ + with `debug_app_quit` before launching another." + ) + ); + assert!(session.workspace.starts_with(root)); +} + +#[test] +fn preview_names_the_workspace_and_the_state_handling() { + let fresh = format_preview(None, "Debug", true, false, None); + assert!(fresh.contains("tmp/debug-app/workspace (scratch, created if missing)")); + assert!(fresh.contains("State directory is emptied first")); + assert!(!fresh.contains("MallocStackLogging")); + + let kept = format_preview(Some("/repo"), "Release", false, false, None); + assert!(kept.contains("JP_WORKSPACE=/repo")); + assert!(kept.contains("just build-app Release")); + assert!(kept.contains("State directory is kept")); +} + +/// Asking for allocation stacks costs 2x to 10x on every timing in the session, +/// so approving the launch means seeing that. +#[test] +fn preview_names_the_cost_of_allocation_stacks() { + let preview = format_preview(None, "Debug", true, true, None); + + assert!(preview.contains("--env MallocStackLogging=1"), "{preview}"); + assert!( + preview.contains("2x\nto 10x"), + "unexpected preview: {preview}" + ); +} + +/// Everything after `--args` goes to the app rather than to `open`, so the +/// appearance has to be the last thing on the command line: an `open` flag +/// after it would be swallowed by the app and silently do nothing. +#[test] +fn appearance_is_passed_to_the_app_after_every_open_flag() { + let args = open_args(&styled_spec(false, Some(Appearance::Dark))); + + assert_eq!(args.iter().rev().take(3).rev().collect::>(), [ + "--args", + "-AppleInterfaceStyle", + "Dark" + ]); +} + +/// Both arguments go after one `--args`. +/// A second `--args` would be handed to the app as a literal argument rather +/// than starting another list, so the flag after it would be read by nobody. +#[test] +fn every_app_argument_goes_after_a_single_args_marker() { + let args = open_args(&windowed_spec(false, Some(Appearance::Dark), true)); + + assert_eq!( + args.iter().filter(|arg| *arg == "--args").count(), + 1, + "{args:?}" + ); + assert_eq!(args.iter().rev().take(5).rev().collect::>(), [ + "--args", + "-ApplePersistenceIgnoreState", + "YES", + "-AppleInterfaceStyle", + "Dark" + ]); +} + +/// A window restored to no size is absent from the window server's list, so it +/// can be neither read, captured, nor resized back: a fresh run has to be free +/// of whatever the last one saved. +#[test] +fn a_fresh_run_ignores_the_window_state_appkit_saved() { + let args = open_args(&windowed_spec(false, None, true)); + + assert!( + args.contains(&"-ApplePersistenceIgnoreState".to_owned()), + "{args:?}" + ); +} + +/// Keeping the state is what `fresh: false` is for, and window restoration is +/// most of what there is to observe about it. +#[test] +fn a_run_keeping_its_state_restores_its_windows() { + let args = open_args(&windowed_spec(false, None, false)); + + assert!( + !args.contains(&"-ApplePersistenceIgnoreState".to_owned()), + "{args:?}" + ); +} + +/// Light is named rather than left out, so the app draws light on a machine set +/// to dark instead of following it. +#[test] +fn light_appearance_is_named_explicitly() { + let args = open_args(&styled_spec(false, Some(Appearance::Light))); + + assert!(args.contains(&"Light".to_owned()), "{args:?}"); +} + +/// Following the system is the default, and passes nothing at all: an app given +/// an empty `--args` list is not the same as one given none. +#[test] +fn no_appearance_passes_no_arguments_to_the_app() { + let args = open_args(&spec(false)); + + assert!(!args.contains(&"--args".to_owned()), "{args:?}"); +} + +#[test] +fn appearance_refuses_a_value_that_is_neither_light_nor_dark() { + let error = Appearance::parse("sepia").unwrap_err().to_string(); + + assert!( + error.starts_with("`appearance` takes `light` or `dark`"), + "{error}" + ); +} + +/// Approving a launch means seeing that the app will not follow the machine's +/// own appearance. +#[test] +fn preview_names_the_appearance_it_forces() { + let preview = format_preview(None, "Debug", true, false, Some(Appearance::Dark)); + + assert!( + preview.contains("--args -AppleInterfaceStyle Dark"), + "{preview}" + ); + assert!(preview.contains("draws in dark appearance"), "{preview}"); +} diff --git a/.config/jp/tools/src/debug_app/marks.rs b/.config/jp/tools/src/debug_app/marks.rs new file mode 100644 index 000000000..96a6008bc --- /dev/null +++ b/.config/jp/tools/src/debug_app/marks.rs @@ -0,0 +1,190 @@ +//! When each driven step ran. +//! +//! The app's stream says how long a piece of work took; it says nothing about +//! what asked for that work. +//! `debug_app_drive` knows, so it writes a line per step naming the step and +//! the wall-clock window it occupied, and a report intersects the two. +//! +//! Beside the app's stream rather than inside it. +//! That file belongs to the app: it is opened by the process being observed, +//! appended to from whichever thread ends an interval, and a second writer +//! would interleave with it. +//! +//! An interval belongs to the step whose window holds the moment it *began*, +//! not the moment it ended. +//! A selection's read runs on its own task, so the harness sees the sidebar +//! change and moves on while the transcript is still loading: attributing on +//! the end instead files an 85ms selection under the following step, or under +//! no step at all when it outlives the run. +//! +//! Windows overlap what the harness did as well as what the app did — reading +//! the accessibility tree between steps takes longer than most steps do — so a +//! window bounds attribution rather than measuring it. +//! What it bounds is enough: the app traces nothing while nobody is driving it, +//! so work that began inside a step's window was asked for by that step. +//! +//! Kept across runs, and swept on the same terms as everything else a slot +//! holds. +//! A step number repeats between runs, so each run of the harness stamps its +//! lines with an id of its own and a report scopes by that. + +use std::{fs, time::Duration}; + +use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; + +use crate::{Error, debug_app::capture::unix_millis}; + +/// Where the marks live, inside a slot's directory. +/// +/// Outside the state directory on purpose: a launch with `fresh` empties that, +/// and losing every earlier run's step boundaries with it would leave the +/// archived streams unattributable. +const MARKS_FILE: &str = "steps.jsonl"; + +/// One step, and the window it occupied. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Mark { + /// The run of the harness this step belonged to. + pub run: String, + + /// Its position in that run's list, counting from one. + pub step: usize, + + /// The step as a report names it. + pub label: String, + + /// When the harness began the step, in milliseconds since the epoch. + pub began_ms: u64, + + /// When it finished reading the result, in milliseconds since the epoch. + pub ended_ms: u64, +} + +impl Mark { + /// Whether `at_ms` falls inside this step's window. + /// + /// Called with the moment an interval began. + /// See the module documentation for why that rather than the moment it + /// ended. + pub(crate) const fn holds(&self, at_ms: u64) -> bool { + at_ms >= self.began_ms && at_ms <= self.ended_ms + } +} + +/// An id for a run of the harness. +pub(crate) fn new_run() -> String { + format!("drive-{}", unix_millis()) +} + +/// The current wall clock, in milliseconds since the epoch. +pub(crate) fn now_ms() -> u64 { + unix_millis() +} + +/// Where a slot keeps its step boundaries. +pub(crate) fn path(dir: &Utf8Path) -> Utf8PathBuf { + dir.join(MARKS_FILE) +} + +/// Append `marks` to the slot's record of what has been driven. +pub(crate) fn append(dir: &Utf8Path, marks: &[Mark]) -> Result<(), Error> { + if marks.is_empty() { + return Ok(()); + } + + let mut lines = String::new(); + for mark in marks { + lines.push_str(&serde_json::to_string(mark)?); + lines.push('\n'); + } + + fs::create_dir_all(dir)?; + let path = path(dir); + let existing = fs::read_to_string(&path).unwrap_or_default(); + + fs::write(&path, format!("{existing}{lines}")) + .map_err(|e| format!("Failed to write {path}: {e}").into()) +} + +/// Drop the marks older than `window`, and report how many went. +/// +/// Lines rather than the file, because one file holds every run a slot has +/// driven: deleting it would take the runs still inside the window with it. +/// Rewritten only when something actually expired, so the ordinary sweep of a +/// slot driven today touches nothing. +pub(crate) fn sweep(dir: &Utf8Path, window: Duration) -> usize { + let held = load(dir); + if held.is_empty() { + return 0; + } + + let cutoff = unix_millis().saturating_sub(window.as_millis().try_into().unwrap_or(u64::MAX)); + let kept: Vec<&Mark> = held.iter().filter(|mark| mark.ended_ms >= cutoff).collect(); + + let expired = held.len() - kept.len(); + if expired == 0 { + return 0; + } + + let mut lines = String::new(); + for mark in kept { + if let Ok(line) = serde_json::to_string(mark) { + lines.push_str(&line); + lines.push('\n'); + } + } + + // A failed rewrite leaves the file as it was, which is the safe direction: + // stale marks attribute nothing to a step that no longer exists, and the next + // sweep tries again. + if fs::write(path(dir), lines).is_err() { + return 0; + } + + expired +} + +/// Every mark this slot has, oldest first. +/// +/// A malformed line is skipped rather than fatal, the same way the trace parser +/// treats one: a truncated trailing line should not lose the report. +pub(crate) fn load(dir: &Utf8Path) -> Vec { + let Ok(raw) = fs::read_to_string(path(dir)) else { + return Vec::new(); + }; + + let mut marks: Vec = raw + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + + marks.sort_by_key(|mark| mark.began_ms); + marks +} + +/// The marks belonging to the most recent run of the harness. +pub(crate) fn latest_run(marks: &[Mark]) -> Vec { + let Some(run) = marks.last().map(|mark| mark.run.clone()) else { + return Vec::new(); + }; + + marks + .iter() + .filter(|mark| mark.run == run) + .cloned() + .collect() +} + +/// The marks whose windows overlap `[from_ms, to_ms]`. +pub(crate) fn overlapping(marks: &[Mark], from_ms: u64, to_ms: u64) -> Vec { + marks + .iter() + .filter(|mark| mark.began_ms <= to_ms && mark.ended_ms >= from_ms) + .cloned() + .collect() +} + +#[cfg(test)] +#[path = "marks_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/marks_tests.rs b/.config/jp/tools/src/debug_app/marks_tests.rs new file mode 100644 index 000000000..13386593a --- /dev/null +++ b/.config/jp/tools/src/debug_app/marks_tests.rs @@ -0,0 +1,136 @@ +use std::time::Duration; + +use super::{Mark, append, latest_run, load, overlapping, sweep}; +use crate::debug_app::capture::unix_millis; + +fn mark(run: &str, step: usize, began_ms: u64, ended_ms: u64) -> Mark { + Mark { + run: run.to_owned(), + step, + label: format!("select sidebar.row.{step}"), + began_ms, + ended_ms, + } +} + +#[test] +fn a_mark_holds_the_moments_inside_its_window() { + let mark = mark("drive-1", 1, 1_000, 2_000); + + assert!(mark.holds(1_000)); + assert!(mark.holds(1_500)); + assert!(mark.holds(2_000)); + assert!(!mark.holds(999)); + assert!(!mark.holds(2_001)); +} + +#[test] +fn marks_round_trip_and_accumulate_across_runs() { + let dir = camino_tempfile::tempdir().unwrap(); + + append(dir.path(), &[mark("drive-1", 1, 1_000, 2_000)]).unwrap(); + append(dir.path(), &[ + mark("drive-2", 1, 5_000, 6_000), + mark("drive-2", 2, 6_000, 7_000), + ]) + .unwrap(); + + let loaded = load(dir.path()); + + assert_eq!(loaded.len(), 3); + assert_eq!(loaded[0], mark("drive-1", 1, 1_000, 2_000)); + assert_eq!(loaded[2], mark("drive-2", 2, 6_000, 7_000)); +} + +/// A step number repeats between runs, so "step 1" alone is ambiguous and the +/// most recent run is what a caller asking about the drive they just did means. +#[test] +fn the_latest_run_is_the_last_one_appended() { + let marks = vec![ + mark("drive-1", 1, 1_000, 2_000), + mark("drive-2", 1, 5_000, 6_000), + mark("drive-2", 2, 6_000, 7_000), + ]; + + let latest = latest_run(&marks); + + assert_eq!(latest.len(), 2); + assert!(latest.iter().all(|mark| mark.run == "drive-2")); +} + +#[test] +fn nothing_driven_has_no_latest_run() { + assert_eq!(latest_run(&[]), Vec::new()); +} + +#[test] +fn overlapping_keeps_a_step_that_straddles_the_window_edge() { + let marks = vec![ + mark("drive-1", 1, 1_000, 2_000), + mark("drive-1", 2, 2_000, 3_000), + mark("drive-1", 3, 9_000, 9_500), + ]; + + let found = overlapping(&marks, 2_500, 4_000); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].step, 2); +} + +/// One file holds every run a slot has driven, so expiry is by line: deleting +/// the file would take the runs still inside the window with it. +#[test] +fn sweeping_drops_the_expired_runs_and_keeps_the_rest() { + let dir = camino_tempfile::tempdir().unwrap(); + let now = unix_millis(); + let hour = 60 * 60 * 1000; + + append(dir.path(), &[ + mark("drive-old", 1, now - 5 * hour, now - 5 * hour + 100), + mark("drive-recent", 1, now - hour, now - hour + 100), + mark("drive-recent", 2, now - hour + 100, now - hour + 200), + ]) + .unwrap(); + + assert_eq!(sweep(dir.path(), Duration::from_hours(2)), 1); + + let kept = load(dir.path()); + assert_eq!(kept.len(), 2); + assert!(kept.iter().all(|mark| mark.run == "drive-recent")); +} + +/// A slot driven today has nothing to expire, and its file is left untouched +/// rather than rewritten. +#[test] +fn sweeping_a_slot_with_nothing_expired_rewrites_nothing() { + let dir = camino_tempfile::tempdir().unwrap(); + let now = unix_millis(); + append(dir.path(), &[mark("drive-1", 1, now - 1_000, now - 900)]).unwrap(); + + let before = std::fs::read_to_string(super::path(dir.path())).unwrap(); + + assert_eq!(sweep(dir.path(), Duration::from_hours(1)), 0); + assert_eq!( + std::fs::read_to_string(super::path(dir.path())).unwrap(), + before + ); +} + +#[test] +fn sweeping_a_slot_that_never_drove_anything_finds_nothing() { + let dir = camino_tempfile::tempdir().unwrap(); + + assert_eq!(sweep(dir.path(), Duration::from_mins(1)), 0); +} + +#[test] +fn a_malformed_line_is_skipped_rather_than_fatal() { + let dir = camino_tempfile::tempdir().unwrap(); + append(dir.path(), &[mark("drive-1", 1, 1_000, 2_000)]).unwrap(); + + let path = super::path(dir.path()); + let raw = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, format!("{raw}{{\"run\": truncated")).unwrap(); + + assert_eq!(load(dir.path()).len(), 1); +} diff --git a/.config/jp/tools/src/debug_app/pixels.rs b/.config/jp/tools/src/debug_app/pixels.rs new file mode 100644 index 000000000..b5046f08b --- /dev/null +++ b/.config/jp/tools/src/debug_app/pixels.rs @@ -0,0 +1,332 @@ +//! `debug_app_pixels` — what colour something is, and how wide it is. +//! +//! The escalation from the accessibility tree for anything *drawn*. +//! A tree read gives the frame of every element, which settles where a text +//! field or a row sits, but a divider, a selection fill, a row separator and a +//! rounded border are not elements: they have no frame to ask for and no colour +//! to report. +//! A scanline across the window has all four in it. +//! +//! Reads in **pixels**, not points. +//! That is the unit the questions arrive in — "the line is 2px and should be +//! 2px" — and it is the unit that makes the retina factor visible instead of +//! hiding it: a one-point line reads as a run of two. +//! The window's size is reported both ways so the conversion is at hand. +//! +//! Captures with `screencapture` and scans with `jpdrive pixels`, which is +//! where the image decoding lives. +//! Each capture is kept, timestamped, beside the ones `debug_app_screenshot` +//! writes, so a scan can be re-read or attached. + +use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, +}; + +use camino::{Utf8Path, Utf8PathBuf}; +use jp_tool::Outcome; +use serde::Deserialize; + +use crate::{ + Context, Error, Tool, + debug_app::{ + driver, + screenshot::{self, Window}, + session::{Session, Slot}, + }, + util::{ + ToolResult, error, + runner::{DuctProcessRunner, ProcessRunner}, + }, +}; + +/// Which way a scan runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +enum Axis { + /// Left to right, across one row. + Row, + + /// Top to bottom, down one column. + Column, +} + +impl Axis { + const fn as_str(self) -> &'static str { + match self { + Self::Row => "row", + Self::Column => "column", + } + } + + /// What the offsets along this scan measure. + const fn along(self) -> &'static str { + match self { + Self::Row => "x", + Self::Column => "y", + } + } +} + +/// What the caller asked to be scanned. +#[derive(Debug)] +struct Args { + /// Which way to scan. + scan: Axis, + + /// The row or column to read, in pixels from the top or the left. + at: u32, + + /// Where along the scan to start, in pixels. + /// The near edge when absent. + from: Option, + + /// Where along the scan to stop, inclusive. + /// The far edge when absent. + to: Option, + + /// A PNG to scan instead of capturing a fresh one. + /// + /// For re-reading a capture a previous call left behind, at another line or + /// another range, without disturbing the app. + image: Option, +} + +impl Args { + fn from_tool(t: &Tool) -> Result { + Ok(Self { + scan: t.req("scan")?, + at: t.req("at")?, + from: t.opt("from")?, + to: t.opt("to")?, + image: t.opt("image")?, + }) + } +} + +/// What `jpdrive pixels` reports. +#[derive(Debug, Deserialize)] +struct Scan { + width: u32, + height: u32, + color_space: String, + scan: String, + at: u32, + runs: Vec, +} + +/// One stretch of identical pixels. +#[derive(Debug, Deserialize)] +struct Run { + start: u32, + count: u32, + color: String, +} + +/// Tool entrypoint. +#[allow(clippy::unused_async, reason = "awaited by the debug_app dispatcher")] +pub(crate) async fn debug_app_pixels(ctx: &Context, t: &Tool) -> ToolResult { + let args = Args::from_tool(t)?; + + if ctx.action.is_format_arguments() { + return Ok(format_preview(&args).into()); + } + + if !cfg!(target_os = "macos") { + return error( + "debug_app_pixels only supports macOS: it reads a window through the macOS window \ + server.", + ); + } + + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_millis()); + + let dir = Session::dir(&ctx.root, &Slot::for_context(ctx)); + run(&ctx.root, &dir, millis, &args, &DuctProcessRunner) +} + +fn format_preview(args: &Args) -> String { + format!( + "`debug_app_pixels`\n\nWill execute:\n\n```sh\nscreencapture -l -o -x \ + tmp/debug-app//shot-.png\njpdrive pixels --image --scan {} --at \ + {}\n```\n\nReports the colours along one row or column of the app's frontmost window, as \ + runs\nof identical pixels. Offsets and colours are in pixels and in the image's \ + own\ncolour space.\n\nReads only. Nothing about the app's state is changed.\n", + args.scan.as_str(), + args.at + ) +} + +/// Capture the window if needed, scan it, and report the runs. +fn run( + root: &Utf8Path, + dir: &Utf8Path, + millis: u128, + args: &Args, + runner: &dyn ProcessRunner, +) -> ToolResult { + let bin = driver::locate(root, runner)?; + + let (image, window) = if let Some(path) = &args.image { + (root.join(path), None) + } else { + let session = Session::resolve(dir)?; + let list = screenshot::windows(&bin, session.pid, root, runner)?; + + if !list.screen_recording { + return error(screenshot::NO_SCREEN_RECORDING); + } + + let Some(window) = list.windows.first().cloned() else { + return error(screenshot::no_window(session.pid, &list, "read")); + }; + + let path = dir.join(format!("shot-{millis}.png")); + capture(window.id, &path, root, runner)?; + (path, Some(window)) + }; + + let scan = scan(&bin, &image, args, root, runner)?; + Ok(Outcome::Success { + content: report(root, &image, window.as_ref(), args, &scan), + }) +} + +/// Write a PNG of window `id`. +/// +/// `-o` leaves out the drop shadow, which would otherwise put a wide band of +/// transparent pixels at the start of every scan. +fn capture( + id: u32, + path: &Utf8Path, + root: &Utf8Path, + runner: &dyn ProcessRunner, +) -> Result<(), Error> { + let output = runner + .run( + "screencapture", + &["-l", &id.to_string(), "-o", "-x", path.as_str()], + root, + ) + .map_err(|e| format!("Failed to spawn `screencapture`: {e}"))?; + + if !output.success() { + return Err(format!( + "`screencapture` refused to capture window {id}: {}", + output.stderr.trim_end() + ) + .into()); + } + + if fs::metadata(path).map(|m| m.len()).unwrap_or_default() == 0 { + return Err(format!( + "`screencapture` reported success but left nothing at {path}. The window may have \ + closed while it was being read." + ) + .into()); + } + + Ok(()) +} + +/// Ask the driver for the runs along the requested line. +fn scan( + bin: &Utf8Path, + image: &Utf8Path, + args: &Args, + root: &Utf8Path, + runner: &dyn ProcessRunner, +) -> Result { + let at = args.at.to_string(); + let mut argv = vec![ + "pixels", + "--image", + image.as_str(), + "--scan", + args.scan.as_str(), + "--at", + &at, + ]; + + let from = args.from.map(|value| value.to_string()); + if let Some(from) = &from { + argv.extend(["--from", from]); + } + + let to = args.to.map(|value| value.to_string()); + if let Some(to) = &to { + argv.extend(["--to", to]); + } + + let output = runner + .run(bin.as_str(), &argv, root) + .map_err(|e| format!("Failed to spawn {bin}: {e}"))?; + + if !output.success() { + return Err(format!( + "`jpdrive pixels` refused to scan {image}:\n\n```\n{}\n```", + output.stdout.trim_end() + ) + .into()); + } + + serde_json::from_str(&output.stdout) + .map_err(|e| format!("Failed to parse the scan `jpdrive` reported: {e}").into()) +} + +/// Render the scan report. +/// +/// The runs are a table because that is how they are read: a reader is looking +/// for where one colour stops and the next begins, and comparing an offset +/// against a frame from the accessibility tree. +fn report( + root: &Utf8Path, + image: &Utf8Path, + window: Option<&Window>, + args: &Args, + scan: &Scan, +) -> String { + let shown = image.strip_prefix(root).unwrap_or(image); + let mut report = format!( + "Scanned {} {} of a {}x{} pixel image, in {}.\n\n", + scan.scan, scan.at, scan.width, scan.height, scan.color_space + ); + + if let Some(window) = window { + let scale = scan.width.checked_div(window.width).unwrap_or_default(); + report.push_str(&format!( + "The window is {}x{} points, so the image is {scale}x: one point is {scale} \ + pixels.\n\n", + window.width, window.height + )); + } + + report.push_str(&format!( + "| {} | count | colour |\n| --- | --- | --- |\n", + args.scan.along() + )); + + for run in &scan.runs { + report.push_str(&format!( + "| {} | {} | `{}` |\n", + run.start, run.count, run.color + )); + } + + if scan.runs.is_empty() { + report.push_str("| | | *nothing in range* |\n"); + } + + report.push_str(&format!( + "\nThe capture is at `{shown}`. Pass it as `image` to scan another line of the same \ + picture without disturbing the app.\n" + )); + + report +} + +#[cfg(test)] +#[path = "pixels_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/pixels_tests.rs b/.config/jp/tools/src/debug_app/pixels_tests.rs new file mode 100644 index 000000000..c855a970a --- /dev/null +++ b/.config/jp/tools/src/debug_app/pixels_tests.rs @@ -0,0 +1,191 @@ +use camino::Utf8Path; +use serde_json::{Map, Value, json}; + +use super::{Args, Axis, Run, Scan, Window, report}; +use crate::Tool; + +fn scan(runs: Vec) -> Scan { + Scan { + width: 1800, + height: 900, + color_space: "kCGColorSpaceSRGB".to_owned(), + scan: "row".to_owned(), + at: 100, + runs, + } +} + +fn args(axis: Axis) -> Args { + Args { + scan: axis, + at: 100, + from: None, + to: None, + image: None, + } +} + +fn run(start: u32, count: u32, color: &str) -> Run { + Run { + start, + count, + color: color.to_owned(), + } +} + +/// The whole point of the report: an edge, and the offset it sits at. +#[test] +fn tabulates_the_runs_along_the_scan() { + let report = report( + Utf8Path::new("/repo"), + Utf8Path::new("/repo/tmp/debug-app/test/shot-1730000000123.png"), + Some(&Window { + id: 7412, + title: Some("mac-app".to_owned()), + width: 900, + height: 450, + }), + &args(Axis::Row), + &scan(vec![ + run(0, 560, "#FFFFFF"), + run(560, 2, "#DBDBDB"), + run(562, 1238, "#FFFFFF"), + ]), + ); + + assert_eq!( + report, + "Scanned row 100 of a 1800x900 pixel image, in kCGColorSpaceSRGB.\n\nThe window is \ + 900x450 points, so the image is 2x: one point is 2 pixels.\n\n| x | count | colour |\n| \ + --- | --- | --- |\n| 0 | 560 | `#FFFFFF` |\n| 560 | 2 | `#DBDBDB` |\n| 562 | 1238 | \ + `#FFFFFF` |\n\nThe capture is at `tmp/debug-app/test/shot-1730000000123.png`. Pass it as \ + `image` to scan another line of the same picture without disturbing the app.\n" + ); +} + +/// A column's offsets measure down the image, not across it, and a reader +/// comparing them against a frame needs to know which. +#[test] +fn names_the_axis_the_offsets_measure() { + let report = report( + Utf8Path::new("/repo"), + Utf8Path::new("/repo/shot.png"), + None, + &args(Axis::Column), + &scan(vec![run(0, 900, "#1D1E20")]), + ); + + assert!(report.contains("| y | count | colour |"), "{report}"); +} + +/// Nothing about the retina factor is claimed when there is no window to +/// compare against: an image passed in by path may be a crop, a scaled copy, or +/// from another machine. +#[test] +fn claims_no_scale_for_an_image_it_did_not_capture() { + let report = report( + Utf8Path::new("/repo"), + Utf8Path::new("/repo/shot.png"), + None, + &args(Axis::Row), + &scan(vec![run(0, 1800, "#FFFFFF")]), + ); + + assert!(!report.contains("one point is"), "{report}"); +} + +/// An empty range is a question with an answer, and a table with no rows reads +/// as a broken report rather than as "there is nothing there". +#[test] +fn says_so_when_the_range_holds_nothing() { + let report = report( + Utf8Path::new("/repo"), + Utf8Path::new("/repo/shot.png"), + None, + &args(Axis::Row), + &scan(vec![]), + ); + + assert!(report.contains("*nothing in range*"), "{report}"); +} + +/// The exact document `jpdrive pixels` writes. +/// Nothing else checks that the two sides agree on the key names. +#[test] +fn reads_the_document_the_driver_writes() { + let written = r##"{ + "at" : 560, + "color_space" : "kCGColorSpaceDisplayP3", + "height" : 900, + "runs" : [ + { + "color" : "#DBDBDB", + "count" : 2, + "start" : 560 + } + ], + "scan" : "column", + "width" : 1800 + }"##; + + let scan: Scan = serde_json::from_str(written).unwrap(); + + assert_eq!(scan.width, 1800); + assert_eq!(scan.height, 900); + assert_eq!(scan.color_space, "kCGColorSpaceDisplayP3"); + assert_eq!(scan.scan, "column"); + assert_eq!(scan.at, 560); + assert_eq!(scan.runs.len(), 1); + assert_eq!(scan.runs[0].start, 560); + assert_eq!(scan.runs[0].count, 2); + assert_eq!(scan.runs[0].color, "#DBDBDB"); +} + +/// A tool call carrying `arguments`, as the dispatcher hands one over. +fn called_with(arguments: Value) -> Tool { + let Value::Object(arguments) = arguments else { + panic!("arguments must be an object"); + }; + + Tool { + name: "debug_app_pixels".to_owned(), + arguments, + answers: Map::new(), + options: Map::new(), + } +} + +/// The arguments arrive as JSON from the assistant, so the spellings are part +/// of the tool's contract. +#[test] +fn reads_the_arguments_the_tool_is_called_with() { + let args = Args::from_tool(&called_with( + json!({"scan": "column", "at": 560, "from": 0, "to": 40}), + )) + .unwrap(); + + assert_eq!(args.scan, Axis::Column); + assert_eq!(args.at, 560); + assert_eq!(args.from, Some(0)); + assert_eq!(args.to, Some(40)); + assert_eq!(args.image, None); +} + +#[test] +fn defaults_the_range_to_the_whole_line() { + let args = Args::from_tool(&called_with(json!({"scan": "row", "at": 0}))).unwrap(); + + assert_eq!(args.from, None); + assert_eq!(args.to, None); +} + +/// A scan with no line to read is a mistake worth reporting before anything is +/// captured. +#[test] +fn refuses_a_call_with_no_line_to_read() { + let error = Args::from_tool(&called_with(json!({"scan": "row"}))) + .unwrap_err() + .to_string(); + + assert!(error.contains("Missing argument 'at'"), "{error}"); +} diff --git a/.config/jp/tools/src/debug_app/profile.rs b/.config/jp/tools/src/debug_app/profile.rs new file mode 100644 index 000000000..f607ba6a4 --- /dev/null +++ b/.config/jp/tools/src/debug_app/profile.rs @@ -0,0 +1,548 @@ +//! `debug_app_profile` — open and close an Instruments recording. +//! +//! Profiling is a bracket, not a property of a session. +//! One session can hold several in sequence: drive the app into a state, open a +//! bracket, drive the operation in question, close it, read the summary, carry +//! on. +//! That is what keeps a report about the operation rather than about a +//! mostly-idle app. +//! +//! When the bracket opens decides what it can see, and nothing else does: +//! +//! - With a session running there is a process to attach to. +//! The trace holds that process alone, and closing the bracket is quick. +//! - With no session there is nothing to attach to, so the recorder takes the +//! whole machine. +//! That is the only way to cover the app's own startup, and it costs minutes +//! to close, because every process's samples are exported before the app's +//! can be sifted out of them. +//! +//! There is no parameter for the choice. +//! The only case a flag could express is "a session exists but record +//! everything anyway", which buys nothing. +//! +//! Allocations are the exception to all of that. +//! The Allocations instrument refuses a target of all processes, so it exists +//! only in the attach case — and only against an app `debug_app_launch` was +//! told to keep allocation stacks for. + +use camino::Utf8Path; +use jp_tool::Outcome; + +use crate::{ + Context, Error, Tool, + debug_app::{ + capture::{ + self, Recording, Scope, Spawner, Target, Tier, new_id, parse_tiers, pending, + record_args, + }, + hotspots, + report::{self, Request}, + session::{RealSignals, Session, Signals, Slot}, + }, + util::{ + ToolResult, error, + paths::{self, Shortening, shorten}, + runner::{DuctProcessRunner, ProcessRunner}, + }, +}; + +/// The entitlement the recorder needs of a process it attaches to. +const GET_TASK_ALLOW: &str = "get-task-allow"; + +/// Tool entrypoint. +#[allow(clippy::unused_async, reason = "awaited by the debug_app dispatcher")] +pub(crate) async fn debug_app_profile(ctx: &Context, t: &Tool) -> ToolResult { + let mode: String = t.req("mode")?; + let capture: Vec = t.opt("capture")?.unwrap_or_default(); + let discard = t.opt::("discard")?.unwrap_or(false); + let request = Request::from_tool(t)?; + + match mode.as_str() { + "start" => { + if discard { + return error( + "`discard` applies to `mode: \"stop\"`, which is where a recording is thrown \ + away. Starting one has nothing to discard.", + ); + } + + if let Some(problem) = report_only(&request, "start") { + return error(problem); + } + + let tiers = parse_tiers(&capture)?; + if ctx.action.is_format_arguments() { + return Ok(preview_start(&tiers).into()); + } + guard_macos()?; + + start( + &ctx.root, + &Session::dir(&ctx.root, &Slot::for_context(ctx)), + &tiers, + &DuctProcessRunner, + &capture::RealSpawner, + ) + } + + "stop" => { + if !capture.is_empty() { + return error( + "`capture` applies to `mode: \"start\"`, which is where the instruments are \ + chosen. Stopping a recording reads back whatever it was started with.", + ); + } + + if let Some(problem) = report_only(&request, "stop") { + return error(problem); + } + + if ctx.action.is_format_arguments() { + return Ok(preview_stop(discard).into()); + } + guard_macos()?; + + stop( + &ctx.root, + &Session::dir(&ctx.root, &Slot::for_context(ctx)), + discard, + &RealSignals, + ) + } + + "report" => { + if !capture.is_empty() { + return error( + "`capture` applies to `mode: \"start\"`, which is where the instruments are \ + chosen. A report reads back what a recording already holds.", + ); + } + + if discard { + return error( + "`discard` applies to `mode: \"stop\"`. A report changes nothing and destroys \ + nothing, so there is nothing for it to throw away.", + ); + } + + if ctx.action.is_format_arguments() { + return Ok(preview_report(&request).into()); + } + + report::run( + &ctx.root, + &Session::dir(&ctx.root, &Slot::for_context(ctx)), + &request, + ) + } + + other => error(format!( + "`mode` accepts \"start\", \"stop\" or \"report\", not {other:?}." + )), + } +} + +/// Why report arguments do not apply to opening or closing a bracket. +fn report_only(request: &Request, mode: &str) -> Option { + if request.is_empty() { + return None; + } + + Some(format!( + "{} {} `mode: \"report\"`, which reads back what a recording holds. `mode: \"{mode}\"` \ + records; it has nothing to scope. Drop {} and ask for the report as its own call.", + request + .named() + .iter() + .map(|name| format!("`{name}`")) + .collect::>() + .join(", "), + if request.named().len() == 1 { + "applies to" + } else { + "apply to" + }, + if request.named().len() == 1 { + "it" + } else { + "them" + }, + )) +} + +fn guard_macos() -> Result<(), Error> { + if cfg!(target_os = "macos") { + return Ok(()); + } + + Err("debug_app_profile only supports macOS: it records with Instruments.".into()) +} + +fn preview_start(tiers: &[Tier]) -> String { + let attach = record_args( + Utf8Path::new("tmp/debug-app//profiles/.trace"), + tiers, + Scope::Attach(0), + ) + .join(" "); + let system = record_args( + Utf8Path::new("tmp/debug-app//profiles/.trace"), + tiers, + Scope::System, + ) + .join(" "); + + let retention = "\nThe bundle from an attached recording is kept, so `mode: \"report\"` can \ + ask it further\nquestions. A system-wide one is destroyed at stop: it embeds \ + the environment of every\nprocess it recorded.\n"; + + let allocations = if tiers.contains(&Tier::Allocations) { + "\nAllocation attribution needs `MallocStackLogging` in the app's environment, and \ + libmalloc\nreads that at process start. Against a running session this only works if the \ + app was\nlaunched for it; otherwise start the bracket first and launch into it.\n\nWhat \ + comes back is a bundle for Instruments, not a table: `xctrace export` surfaces\nnone of \ + the Allocations instrument's data. For a machine-readable number, `mode:\n\"report\"` \ + with `view: \"allocations\"` reports the footprint the app measures for itself.\n" + } else { + "" + }; + + format!( + "`debug_app_profile` (start)\n\nWith a session already running, attaches to \ + it:\n\n```sh\nxcrun {attach}\n```\n\nWith no session, records the whole machine, which \ + is the only way to cover the app's\nown startup:\n\n```sh\nxcrun {system}\n```\n\nLeaves \ + a recorder running until `mode: \"stop\"`, which reads a summary out of \ + the\nbundle.\n{retention}{allocations}" + ) +} + +fn preview_report(request: &Request) -> String { + let view = request.view.as_deref().unwrap_or("timeline"); + + format!( + "`debug_app_profile` (report)\n\nReads back what this slot recorded, at `view: \ + \"{view}\"`.\n\nReads only. Nothing is captured, nothing is destroyed, and the offsets \ + `debug_app_snapshot`\nuses to report deltas are left alone — so the same question can be \ + asked again at a\ndifferent scope.\n\n`timeline`, `spans` and `views` come from the \ + app's own intervals and answer while it\nruns. `hotspots`, `callgraph` and `allocations` \ + read a finalized `.trace`, so they\nanswer for closed recordings only.\n" + ) +} + +fn preview_stop(discard: bool) -> String { + let tail = if discard { + "Throws the recording away without reading it, which is what to do after a bracket \ + that\nwent wrong: reading a system-wide one costs minutes.\n" + } else { + "Reads the app's samples out of the bundle and writes a summary beside it. A \ + system-wide\nrecording takes minutes here, because every process's samples are exported \ + before\nthe app's can be sifted out of them.\n" + }; + + format!( + "`debug_app_profile` (stop)\n\nInterrupts the recorder and waits for it to write its \ + bundle out.\n\n{tail}\nAn attached recording's bundle is kept, for `mode: \"report\"` to \ + read again. A\nsystem-wide one is destroyed: a `.trace` embeds the environment of every \ + process\nit recorded.\n" + ) +} + +/// Open a bracket. +fn start( + root: &Utf8Path, + dir: &Utf8Path, + tiers: &[Tier], + runner: &dyn ProcessRunner, + spawner: &dyn Spawner, +) -> ToolResult { + let swept = capture::sweep(dir); + + if let Some(open) = pending(dir) { + return error(format!( + "A recording is already open as `{}`, started by pid {}. Close it with `mode: \ + \"stop\"` before opening another.", + open.id, open.recorder_pid + )); + } + + // The session decides the scope, and there is no parameter for it: with an + // app running there is something to attach to, and without one there is + // not. + let session = Session::load(dir)?.filter(Session::is_running); + let scope = match &session { + Some(session) => Scope::Attach(session.pid), + None => Scope::System, + }; + + if tiers.contains(&Tier::Allocations) { + let Some(session) = &session else { + return error( + "Allocations cannot be recorded with no app running. The instrument refuses a \ + target of all processes, which is the only scope available without something to \ + attach to. Run `debug_app_launch` with `allocation_stacks: true`, then open this \ + bracket.", + ); + }; + + if !session.allocation_stacks { + return error(format!( + "The app running as pid {} was not launched with `allocation_stacks`, so it has \ + kept no allocation stacks and the Allocations instrument would find nothing. \ + libmalloc reads `MallocStackLogging` at process start, so it cannot be added to \ + a running app. Either record the time profile alone, or `debug_app_quit` and \ + `debug_app_launch` again with `allocation_stacks: true`.", + session.pid + )); + } + } + + if let Some(session) = &session { + confirm_debuggable(&session.bundle, root, runner)?; + } + + let (id, started_unix) = new_id(); + let recording = Recording { + id, + tiers: tiers.to_vec(), + scope, + recorder_pid: 0, + started_unix, + stopped_unix: None, + target: None, + }; + + let bundle = recording.bundle(dir); + let log = recording.log(dir); + let recorder_pid = spawner.start( + &record_args(&bundle, tiers, scope), + &log, + root, + capture::READY_TIMEOUT, + )?; + + let recording = Recording { + recorder_pid, + ..recording + }; + + // A record that failed to land would leave a recorder nothing can find and + // a bundle nothing will collect. + if let Err(e) = recording.store(dir) { + capture::stop(recorder_pid, &RealSignals, capture::FINALIZE_TIMEOUT); + drop(recording.discard(dir)); + return Err(e); + } + + Ok(Outcome::Success { + content: report_start(&recording, session.as_ref(), &swept), + }) +} + +/// Close a bracket. +fn stop(root: &Utf8Path, dir: &Utf8Path, discard: bool, signals: &dyn Signals) -> ToolResult { + let Some(mut recording) = pending(dir) else { + return error(format!( + "No recording is open in {}. Open one with `mode: \"start\"`.", + capture::profiles_dir(dir) + )); + }; + + let (outcome, elapsed) = + capture::stop(recording.recorder_pid, signals, capture::FINALIZE_TIMEOUT); + + if outcome == capture::Stop::Stuck { + return error(format!( + "The recorder (pid {}) had not finished writing `{}` after {}s, and was left running: \ + `xctrace` finalizes on its way out, so killing it leaves a bundle nothing can open. \ + Give it longer and stop again, or send it `kill -INT {}` and delete the bundle by \ + hand once it has gone — it holds the environment of every process it recorded.", + recording.recorder_pid, + recording.id, + capture::FINALIZE_TIMEOUT.as_secs(), + recording.recorder_pid, + )); + } + + let said = recording.said(dir); + + if discard { + recording.discard(dir)?; + + return Ok(Outcome::Success { + content: format!( + "Discarded `{}` after {elapsed:.1?}, unread. The bundle is gone.\n", + recording.id + ), + }); + } + + if !recording.bundle(dir).exists() { + recording.discard(dir)?; + + return error(format!( + "The recorder left no bundle for `{}`. It said:\n\n```\n{}\n```", + recording.id, + said.trim_end() + )); + } + + // Stamped and stored before the read, for two reasons. A read that failed + // must not leave a bracket that looks open, and a retained recording has to + // answer for itself once `debug_app_quit` has removed the session record. + let target = Session::load(dir)?.as_ref().map(Target::for_session); + recording.close(target.clone(), dir)?; + + // A system-wide bundle goes whatever the read did: it is credential + // material, and a failure that leaves one behind is the case nobody is + // watching. An attach bundle holds this app's environment alone and stays. + let shortenings = paths::shortenings(root); + let summary = hotspots::summarize(&recording, dir, target.as_ref(), &shortenings); + recording.retire(dir)?; + let summary = summary?; + + Ok(Outcome::Success { + content: report_stop(&recording, &summary, outcome, elapsed, &said, &shortenings), + }) +} + +/// Confirm the app can be attached to. +/// +/// Sampling a process is `task_for_pid`, which wants `get-task-allow`. +/// Worth confirming rather than assuming, because staging replaces the +/// signature the build produced: rewriting `Info.plist` invalidates it, and the +/// ad-hoc re-sign that follows is what the entitlement has to survive. +fn confirm_debuggable( + bundle: &Utf8Path, + root: &Utf8Path, + runner: &dyn ProcessRunner, +) -> Result<(), Error> { + let output = runner + .run( + "codesign", + &["-d", "--entitlements", "-", "--xml", bundle.as_str()], + root, + ) + .map_err(|e| format!("Failed to spawn `codesign`: {e}"))?; + + // Both streams, because codesign puts the entitlements on one and its + // commentary on the other, and which is which has moved between releases. + let reported = format!("{}{}", output.stdout, output.stderr); + if reported.contains(GET_TASK_ALLOW) { + return Ok(()); + } + + Err(format!( + "The app at {bundle} does not carry `{GET_TASK_ALLOW}`, so the recorder cannot attach to \ + it. `codesign` reported:\n\n```\n{}\n```", + reported.trim_end() + ) + .into()) +} + +fn report_start(recording: &Recording, session: Option<&Session>, swept: &[String]) -> String { + let mut out = format!( + "Opened `{}`, recording {}.\n", + recording.id, + recording.describe() + ); + + match session { + Some(session) => out.push_str(&format!( + "\nAttached to the app (pid {}), so the trace holds that process alone and closing \ + this bracket is quick.\n", + session.pid + )), + None => out.push_str( + "\nNo app was running, so this records **every process on the machine** — the only \ + way to cover an app's own startup. Closing the bracket will take minutes, because \ + every process's samples are exported before the app's can be sifted out of them. \ + `debug_app_launch` now to put an app inside the recording.\n", + ), + } + + if recording.holds(Tier::Allocations) { + out.push_str( + "\n**Timings in this bracket are distorted.** `MallocStackLogging` costs 2x to 10x \ + and not evenly: allocation-heavy paths slow disproportionately, so one operation \ + looking 3x another may mean only that it allocates more.\n", + ); + out.push_str( + "\n**Nothing here will be machine-readable.** The Allocations instrument writes to \ + the trace event store rather than to a table, and `xctrace export` surfaces none of \ + it, so the stacks are reachable only by opening the bundle in Instruments. For a \ + number an agent can act on, `view: \"allocations\"` reports the footprint the app \ + measures for itself — on every run, at no cost, with no bracket at all.\n", + ); + } + + out.push_str("\nClose it with `mode: \"stop\"`, which reads a summary out of the bundle.\n"); + + if recording.keeps_bundle() { + out.push_str( + "The bundle is then kept, so `mode: \"report\"` can ask it further questions.\n", + ); + } else { + out.push_str( + "The bundle is then destroyed: recorded system-wide, it embeds the environment of \ + every process on the machine.\n", + ); + } + + out.push_str(&swept_note(swept)); + out +} + +fn report_stop( + recording: &Recording, + summary: &hotspots::Summary, + outcome: capture::Stop, + elapsed: std::time::Duration, + said: &str, + shortenings: &[Shortening], +) -> String { + let mut out = match outcome { + capture::Stop::Absent => format!( + "Closed `{}`. The recorder had already exited on its own, so it stopped recording at \ + some point before this.\n", + recording.id + ), + _ => format!( + "Closed `{}`. The recorder finished writing its bundle in {elapsed:.1?}.\n", + recording.id + ), + }; + + if capture::run_issues(said) { + out.push_str(&format!( + "\nIt reported run issues, so parts of the trace may be missing. What it \ + said:\n\n```\n{}\n```\n", + capture::run_issue_lines(said) + )); + } + + out.push_str(&format!( + "\nSummary at `{}`:\n\n{}", + shorten(summary.path.as_str(), shortenings), + summary.content + )); + out +} + +/// What a sweep reclaimed, when it reclaimed anything. +pub(crate) fn swept_note(swept: &[String]) -> String { + if swept.is_empty() { + return String::new(); + } + + format!( + "\nAlso reclaimed {} earlier artifact(s): {}. A system-wide bundle goes as soon as it has \ + been read, and everything kept beyond that is bounded by age and by a byte budget.\n", + swept.len(), + swept.join(", ") + ) +} + +#[cfg(test)] +#[path = "profile_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/profile_tests.rs b/.config/jp/tools/src/debug_app/profile_tests.rs new file mode 100644 index 000000000..eeaae97f8 --- /dev/null +++ b/.config/jp/tools/src/debug_app/profile_tests.rs @@ -0,0 +1,390 @@ +use std::{fs, sync::Mutex, time::Duration}; + +use camino::{Utf8Path, Utf8PathBuf}; + +use super::{start, stop}; +use crate::{ + Error, + debug_app::{ + capture::{Recording, Scope, Spawner, Tier, pending, unix_seconds}, + session::{Console, RealSignals, Session, Signal, Signals, Slot}, + }, + util::runner::MockProcessRunner, +}; + +/// A recorder that is already gone, so a stop resolves without waiting. +struct Gone; + +impl Signals for Gone { + fn send(&self, _pid: u32, _signal: Signal) {} + + fn is_alive(&self, _pid: u32) -> bool { + false + } +} + +/// What `codesign -d --entitlements -` prints for a bundle that can be attached +/// to. +const ENTITLEMENTS: &str = r#" +com.apple.security.get-task-allow"#; + +/// A [`Spawner`] that records the command line and hands back a pid. +struct FakeSpawner { + started: Mutex>>, + pid: u32, +} + +impl FakeSpawner { + fn returning(pid: u32) -> Self { + Self { + started: Mutex::new(Vec::new()), + pid, + } + } + + fn started(&self) -> Vec> { + self.started.lock().unwrap().clone() + } +} + +impl Spawner for FakeSpawner { + fn start( + &self, + args: &[String], + log: &Utf8Path, + _working_dir: &Utf8Path, + _timeout: Duration, + ) -> Result { + self.started.lock().unwrap().push(args.to_vec()); + + // The real one leaves what the recorder said here, and `stop` reads it + // back looking for run issues. + if let Some(parent) = log.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(log, "Ctrl-C to stop the recording\n").unwrap(); + + Ok(self.pid) + } +} + +/// A [`Spawner`] that fails the test if a recorder is started. +struct NeverSpawns; + +impl Spawner for NeverSpawns { + fn start( + &self, + args: &[String], + _log: &Utf8Path, + _working_dir: &Utf8Path, + _timeout: Duration, + ) -> Result { + panic!("the recorder was started: {args:?}"); + } +} + +fn temp() -> (camino_tempfile::Utf8TempDir, Utf8PathBuf) { + let workspace = camino_tempfile::tempdir().unwrap(); + let dir = Session::dir(workspace.path(), &Slot::fixed("test")); + + (workspace, dir) +} + +/// Record a running session, including the pid file the app writes. +fn running_session(dir: &Utf8Path, allocation_stacks: bool) -> Session { + let pid = std::process::id(); + let session = Session { + pid, + bundle: "/derived/JP.app".into(), + configuration: "Debug".to_owned(), + workspace: "/repo".into(), + state_dir: dir.join("state"), + user_data_dir: dir.join("data"), + stdout: Console::new(dir.join("console.out")), + stderr: Console::new(dir.join("console.err")), + trace: Console::new(dir.join("state/trace.jsonl")), + reported_footprint_mb: None, + dsym: None, + allocation_stacks, + }; + + session.store(dir).unwrap(); + fs::create_dir_all(&session.state_dir).unwrap(); + fs::write(session.pid_path(), format!("{pid}\n")).unwrap(); + session +} + +fn content(outcome: jp_tool::Outcome) -> String { + match outcome { + jp_tool::Outcome::Success { content } => content, + jp_tool::Outcome::Error { message, .. } => message, + other @ jp_tool::Outcome::NeedsInput { .. } => panic!("unexpected outcome: {other:?}"), + } +} + +/// With an app running there is a process to attach to, and the trace holds it +/// alone. +/// Nothing tells the tool which to use — the ordering does. +#[test] +fn starting_against_a_running_app_attaches_to_it() { + let (workspace, dir) = temp(); + let session = running_session(&dir, false); + let spawner = FakeSpawner::returning(4321); + let runner = MockProcessRunner::builder() + .expect("codesign") + .returns_success(ENTITLEMENTS); + + let report = + content(start(workspace.path(), &dir, &[Tier::Sampling], &runner, &spawner).unwrap()); + + let started = spawner.started(); + assert_eq!(started.len(), 1, "{started:?}"); + assert!( + started[0].contains(&"--attach".to_owned()) + && started[0].contains(&session.pid.to_string()), + "expected an attach: {:?}", + started[0] + ); + assert!( + !started[0].contains(&"--all-processes".to_owned()), + "{:?}", + started[0] + ); + + assert!( + report.contains(&format!("Attached to the app (pid {})", session.pid)), + "{report}" + ); + + let open = pending(&dir).expect("the bracket should be recorded"); + assert_eq!(open.scope, Scope::Attach(session.pid)); + assert_eq!(open.recorder_pid, 4321); +} + +/// With no app there is nothing to attach to, so the recorder takes the +/// machine. +/// That is the only way to cover a launch, and the report has to say what it +/// will cost. +#[test] +fn starting_with_no_app_records_the_machine_and_says_what_that_costs() { + let (workspace, dir) = temp(); + let spawner = FakeSpawner::returning(4321); + + // Nothing to check the entitlement of, so codesign is never run. + let runner = MockProcessRunner::never_called(); + + let report = + content(start(workspace.path(), &dir, &[Tier::Sampling], &runner, &spawner).unwrap()); + + let started = spawner.started(); + assert!( + started[0].contains(&"--all-processes".to_owned()), + "{:?}", + started[0] + ); + assert!( + !started[0].contains(&"--attach".to_owned()), + "{:?}", + started[0] + ); + + assert!(report.contains("every process on the machine"), "{report}"); + assert!(report.contains("will take minutes"), "{report}"); + + assert_eq!(pending(&dir).map(|r| r.scope), Some(Scope::System)); +} + +/// libmalloc reads `MallocStackLogging` at process start, so an app launched +/// without it kept no stacks and the instrument would find nothing. +/// Refusing and naming the relaunch beats recording an empty table. +#[test] +fn allocations_against_an_app_launched_without_them_is_refused() { + let (workspace, dir) = temp(); + let session = running_session(&dir, false); + let runner = MockProcessRunner::never_called(); + + let report = content( + start( + workspace.path(), + &dir, + &[Tier::Sampling, Tier::Allocations], + &runner, + &NeverSpawns, + ) + .unwrap(), + ); + + assert!( + report.contains(&format!( + "The app running as pid {} was not launched with `allocation_stacks`", + session.pid + )), + "unexpected report: {report}" + ); + assert!( + report.contains("`debug_app_launch` again with `allocation_stacks: true`"), + "{report}" + ); + assert_eq!(pending(&dir), None); +} + +/// `xctrace` reports `Allocations cannot handle a target type of 'All +/// Processes'` and then fails the whole recording, so the combination has to be +/// refused before a recorder is ever started. +#[test] +fn allocations_with_no_app_running_is_refused() { + let (workspace, dir) = temp(); + let runner = MockProcessRunner::never_called(); + + let report = content( + start( + workspace.path(), + &dir, + &[Tier::Sampling, Tier::Allocations], + &runner, + &NeverSpawns, + ) + .unwrap(), + ); + + assert!( + report.starts_with("Allocations cannot be recorded with no app running."), + "unexpected report: {report}" + ); + assert!( + report.contains("`debug_app_launch` with `allocation_stacks: true`"), + "{report}" + ); + assert_eq!(pending(&dir), None); +} + +/// The one ordering that works: an app launched to keep allocation stacks, then +/// a bracket attached to it. +#[test] +fn allocations_are_accepted_against_an_app_launched_for_them() { + let (workspace, dir) = temp(); + let session = running_session(&dir, true); + let spawner = FakeSpawner::returning(4321); + let runner = MockProcessRunner::builder() + .expect("codesign") + .returns_success(ENTITLEMENTS); + + start( + workspace.path(), + &dir, + &[Tier::Sampling, Tier::Allocations], + &runner, + &spawner, + ) + .unwrap(); + + let open = pending(&dir).expect("the bracket should be recorded"); + assert!(open.holds(Tier::Allocations)); + assert_eq!(open.scope, Scope::Attach(session.pid)); + + let started = &spawner.started()[0]; + assert!(started.contains(&"Allocations".to_owned()), "{started:?}"); + assert!( + !started.contains(&"--all-processes".to_owned()), + "the instrument refuses that target: {started:?}" + ); +} + +#[test] +fn starting_a_second_bracket_is_refused() { + let (workspace, dir) = temp(); + let open = Recording { + id: "profile-1".to_owned(), + tiers: vec![Tier::Sampling], + scope: Scope::System, + recorder_pid: std::process::id(), + started_unix: unix_seconds(), + stopped_unix: None, + target: None, + }; + open.store(&dir).unwrap(); + + let runner = MockProcessRunner::never_called(); + let report = content( + start( + workspace.path(), + &dir, + &[Tier::Sampling], + &runner, + &NeverSpawns, + ) + .unwrap(), + ); + + assert!( + report.starts_with("A recording is already open as `profile-1`"), + "unexpected report: {report}" + ); +} + +/// An app whose ad-hoc re-sign dropped the entitlement cannot be attached to, +/// and the recorder would produce nothing. +#[test] +fn starting_against_an_app_that_cannot_be_attached_to_fails() { + let (workspace, dir) = temp(); + running_session(&dir, false); + let runner = MockProcessRunner::builder() + .expect("codesign") + .returns_success(""); + + let error = start( + workspace.path(), + &dir, + &[Tier::Sampling], + &runner, + &NeverSpawns, + ) + .unwrap_err() + .to_string(); + + assert!( + error.contains("does not carry `get-task-allow`"), + "unexpected error: {error}" + ); +} + +#[test] +fn stopping_with_no_bracket_open_says_so() { + let (workspace, dir) = temp(); + + let report = content(stop(workspace.path(), &dir, false, &RealSignals).unwrap()); + + assert!( + report.starts_with("No recording is open in"), + "unexpected report: {report}" + ); +} + +/// Reading a system-wide recording costs minutes, so throwing a botched bracket +/// away has to be possible without paying for it. +#[test] +fn discarding_a_bracket_deletes_the_bundle_unread() { + let (workspace, dir) = temp(); + let open = Recording { + id: "profile-1".to_owned(), + tiers: vec![Tier::Sampling], + scope: Scope::System, + recorder_pid: std::process::id(), + started_unix: unix_seconds(), + stopped_unix: None, + target: None, + }; + fs::create_dir_all(open.bundle(&dir)).unwrap(); + fs::write(open.log(&dir), "Ctrl-C to stop the recording\n").unwrap(); + open.store(&dir).unwrap(); + + let report = content(stop(workspace.path(), &dir, true, &Gone).unwrap()); + + assert!( + report.starts_with("Discarded `profile-1`"), + "unexpected report: {report}" + ); + assert!(report.contains("unread"), "{report}"); + assert!(!open.bundle(&dir).exists()); + assert_eq!(pending(&dir), None); +} diff --git a/.config/jp/tools/src/debug_app/quit.rs b/.config/jp/tools/src/debug_app/quit.rs new file mode 100644 index 000000000..3838b26dc --- /dev/null +++ b/.config/jp/tools/src/debug_app/quit.rs @@ -0,0 +1,342 @@ +//! `debug_app_quit` — stop the running app, keeping its state for a relaunch. +//! +//! Removes `session.json`, because that record describes a live process. +//! The state and user-data directories stay: a quit followed by a launch with +//! `fresh = false` is how state restoration gets tested at all. +//! +//! A profile bracket left open is closed first, before the app: `xctrace` +//! attached to a process that is going away has no predictable behaviour, and +//! nobody brackets a quit deliberately. +//! Closing it reports the summary `debug_app_profile` would have, and stamps +//! the record with what it was recording — the last moment at which that is +//! knowable, since the record being removed here is where it comes from. +//! +//! Retained artifacts are swept on the way out, under the age window and byte +//! budget `capture` holds. + +use std::{ + thread, + time::{Duration, Instant}, +}; + +use camino::Utf8Path; +use jp_tool::Outcome; + +use crate::{ + Context, Error, Tool, + debug_app::{ + capture::{self, Recording, Stop, Target}, + hotspots::{self, Summary}, + profile::swept_note, + session::{RealSignals, Session, Signal, Signals, Slot}, + }, + util::{ + ToolResult, error, + paths::{self, Shortening, shorten}, + }, +}; + +/// How long the app is given to exit after `SIGTERM` before it is killed. +const TERM_GRACE: Duration = Duration::from_secs(10); + +/// Poll interval while waiting for the process to disappear. +const POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// How a launched app ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Termination { + /// Already gone when the tool ran. + Absent, + + /// Exited after `SIGTERM`. + Terminated, + + /// Ignored `SIGTERM` and was killed. + Killed, +} + +/// Tool entrypoint. +#[allow(clippy::unused_async, reason = "awaited by the debug_app dispatcher")] +pub(crate) async fn debug_app_quit(ctx: &Context, _t: &Tool) -> ToolResult { + if ctx.action.is_format_arguments() { + return Ok(format_preview().into()); + } + + if !cfg!(target_os = "macos") { + return error( + "debug_app_quit only supports macOS: it stops an app started by `debug_app_launch`.", + ); + } + + let dir = Session::dir(&ctx.root, &Slot::for_context(ctx)); + run(&ctx.root, &dir, TERM_GRACE, &RealSignals) +} + +fn format_preview() -> String { + "`debug_app_quit`\n\nStops the app recorded in `tmp/debug-app/session.json` with `SIGTERM`, \ + escalating to\n`SIGKILL` if it does not exit.\n\nA profile bracket left open is closed first, \ + before the app, and its summary comes\nback here. Closing a system-wide bracket takes \ + minutes, so close it yourself with\n`debug_app_profile` beforehand if that \ + matters.\n\nRemoves the session record. Keeps `tmp/debug-app/state/` and \ + `tmp/debug-app/data/`, so\na following `debug_app_launch` with `fresh = false` reopens what \ + this app had\nopen.\n\nReturns whatever the app wrote to its console since the last call.\n" + .to_owned() +} + +/// What became of a bracket left open. +struct Closed { + id: String, + stop: Stop, + + /// How long the recorder took to write the bundle out. + elapsed: Duration, + + /// What the recorder said about problems with what it recorded, if + /// anything. + issues: String, + + /// The summary that replaced the bundle, or why there is none. + summary: Result, +} + +/// Close a bracket that was left open, and destroy its bundle. +fn close( + recording: &Recording, + dir: &Utf8Path, + session: &Session, + signals: &dyn Signals, + timeout: Duration, + shortenings: &[Shortening], +) -> Closed { + let (stop, elapsed) = capture::stop(recording.recorder_pid, signals, timeout); + let said = recording.said(dir); + let mut recording = recording.clone(); + + let summary = if stop == Stop::Stuck { + Err(format!( + "The recorder (pid {}) had not finished writing `{}` after {}s, and was left running: \ + `xctrace` finalizes on its way out, so killing it leaves a bundle nothing can open. \ + Send it `kill -INT {}`, wait, and then delete the bundle by hand — it holds the \ + environment of every process it recorded.", + recording.recorder_pid, + recording.id, + timeout.as_secs(), + recording.recorder_pid, + )) + } else if recording.bundle(dir).exists() { + // Stamped before the read: the session record is about to be removed, so + // this is the last moment at which the recording can be told what it was + // recording. + let target = Target::for_session(session); + drop(recording.close(Some(target.clone()), dir)); + + // A system-wide bundle goes whatever the read did: it is credential + // material, and a failure that leaves one behind is the case nobody is + // watching. + let read = hotspots::summarize(&recording, dir, Some(&target), shortenings) + .map_err(|e| e.to_string()); + drop(recording.retire(dir)); + read + } else { + drop(recording.discard(dir)); + Err(format!( + "The recorder left no bundle for `{}`. It said:\n\n```\n{}\n```", + recording.id, + said.trim_end() + )) + }; + + Closed { + id: recording.id.clone(), + stop, + elapsed, + issues: if capture::run_issues(&said) { + capture::run_issue_lines(&said) + } else { + String::new() + }, + summary, + } +} + +/// Stop the recorded app and report how it went. +fn run(root: &Utf8Path, dir: &Utf8Path, grace: Duration, signals: &dyn Signals) -> ToolResult { + let shortenings = paths::shortenings(root); + + let Some(mut session) = Session::load(dir)? else { + return error(format!( + "No app session recorded at {}, so there is nothing to stop.", + Session::path(dir) + )); + }; + + // Before the app, because a recorder attached to a process on its way out + // has nothing useful to do and no defined behaviour. + let closed = capture::pending(dir).map(|recording| { + close( + &recording, + dir, + &session, + signals, + capture::FINALIZE_TIMEOUT, + &shortenings, + ) + }); + + let termination = if session.is_running() { + stop(session.pid, grace, signals)? + } else { + Termination::Absent + }; + + // Read the console before dropping the record: these offsets are the only + // thing that knows what has already been reported. + let out = session.stdout.delta()?; + let err = session.stderr.delta()?; + + let path = Session::path(dir); + std::fs::remove_file(&path).map_err(|e| format!("Failed to remove {path}: {e}"))?; + + let swept = capture::sweep(dir); + + Ok(Outcome::Success { + content: report( + &session, + termination, + closed.as_ref(), + &swept, + &out, + &err, + &shortenings, + ), + }) +} + +/// Signal the app and wait for it to go away, escalating once. +fn stop(pid: u32, grace: Duration, signals: &dyn Signals) -> Result { + signals.send(pid, Signal::Term); + if wait_for_exit(pid, grace, signals) { + return Ok(Termination::Terminated); + } + + signals.send(pid, Signal::Kill); + if wait_for_exit(pid, grace, signals) { + return Ok(Termination::Killed); + } + + Err(format!("The app (pid {pid}) is still running after SIGKILL.").into()) +} + +/// Poll until `pid` is gone or `timeout` elapses. +/// `true` when it is gone. +fn wait_for_exit(pid: u32, timeout: Duration, signals: &dyn Signals) -> bool { + let deadline = Instant::now() + timeout; + + loop { + if !signals.is_alive(pid) { + return true; + } + + if Instant::now() >= deadline { + return false; + } + + thread::sleep(POLL_INTERVAL); + } +} + +/// Render the quit report. +fn report( + session: &Session, + termination: Termination, + closed: Option<&Closed>, + swept: &[String], + out: &str, + err: &str, + shortenings: &[Shortening], +) -> String { + let mut report = match termination { + Termination::Absent => format!( + "The app recorded as pid {} was already gone. Cleared the session record.\n", + session.pid + ), + Termination::Terminated => { + format!("Stopped the app (pid {}) with SIGTERM.\n", session.pid) + } + Termination::Killed => format!( + "The app (pid {}) ignored SIGTERM and was killed. Anything it writes only on a clean \ + exit is missing.\n", + session.pid + ), + }; + + report.push_str(&format!( + "\nKept for a relaunch with `fresh = false`:\n\n- state: `{}`\n- user data: `{}`\n", + shorten(session.state_dir.as_str(), shortenings), + shorten(session.user_data_dir.as_str(), shortenings) + )); + + if let Some(closed) = closed { + report.push_str(&render_closed(closed, shortenings)); + } + + report.push_str(&swept_note(swept)); + + for (name, content) in [("stdout", out), ("stderr", err)] { + if content.trim().is_empty() { + continue; + } + + report.push_str(&format!( + "\nConsole ({name}), since the last call:\n\n```\n{}\n```\n", + content.trim_end() + )); + } + + report +} + +/// Render what became of a bracket left open. +fn render_closed(closed: &Closed, shortenings: &[Shortening]) -> String { + let elapsed = format!("{:.1}s", closed.elapsed.as_secs_f64()); + + let mut out = match closed.stop { + Stop::Finalized => format!( + "\nA profile bracket was still open. Closed `{}`; the recorder finished writing its \ + bundle in {elapsed}.\n", + closed.id + ), + Stop::Absent => format!( + "\nA profile bracket was still open as `{}`, but its recorder was already gone.\n", + closed.id + ), + Stop::Stuck => format!( + "\nA profile bracket was still open as `{}`, and its recorder was still writing after \ + {elapsed}.\n", + closed.id + ), + }; + + if !closed.issues.is_empty() { + out.push_str(&format!( + "\nIt reported run issues, so parts of the trace may be missing. What it \ + said:\n\n```\n{}\n```\n", + closed.issues + )); + } + + match &closed.summary { + Ok(summary) => out.push_str(&format!( + "\nSummary at `{}`:\n\n{}", + shorten(summary.path.as_str(), shortenings), + summary.content + )), + Err(why) => out.push_str(&format!("\nNo summary: {why}\n")), + } + + out +} + +#[cfg(test)] +#[path = "quit_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/quit_tests.rs b/.config/jp/tools/src/debug_app/quit_tests.rs new file mode 100644 index 000000000..17e90233b --- /dev/null +++ b/.config/jp/tools/src/debug_app/quit_tests.rs @@ -0,0 +1,277 @@ +use std::{ + fs, + process::Command, + sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::Duration, +}; + +use camino::Utf8Path; + +use super::run; +use crate::debug_app::session::{ + Console, RealSignals, Session, Signal, Signals, Slot, pid_is_alive, +}; + +/// A slot every test in this file shares, so paths are predictable. +fn dir_for(root: &Utf8Path) -> camino::Utf8PathBuf { + Session::dir(root, &Slot::fixed("test")) +} + +/// Above macOS's default maximum pid, so no process can hold it. +const DEAD_PID: u32 = 4_000_000; + +/// Long enough for a real process to notice a signal, short enough not to pad +/// the suite. +const GRACE: Duration = Duration::from_secs(2); + +/// Start a process and reap it on a side thread, returning its pid. +/// +/// The reaper is what makes the process observably disappear. +/// A killed child with nobody waiting on it becomes a zombie, and a zombie +/// still answers `kill(pid, 0)`, so the code under test would poll a process +/// that never goes away. +/// Production does not have this problem: `open(1)` leaves the app parented to +/// launchd, which reaps it. +fn spawn_reaped(program: &str, args: &[&str]) -> u32 { + let child = Command::new(program).args(args).spawn().unwrap(); + let pid = child.id(); + + thread::spawn(move || { + let mut child = child; + drop(child.wait()); + }); + + pid +} + +/// A [`Signals`] that records what it was sent and only dies on `SIGKILL`. +/// +/// Replaces a real process for the escalation ladder, and pins the exact +/// sequence of signals — which no real fixture can assert, since a process +/// cannot report what it ignored. +struct IgnoresTerm { + sent: Mutex>, + alive: AtomicBool, + dies_on: Signal, +} + +impl IgnoresTerm { + fn dying_on(dies_on: Signal) -> Self { + Self { + sent: Mutex::new(Vec::new()), + alive: AtomicBool::new(true), + dies_on, + } + } + + fn sent(&self) -> Vec { + self.sent.lock().unwrap().clone() + } +} + +impl Signals for IgnoresTerm { + fn send(&self, _pid: u32, signal: Signal) { + self.sent.lock().unwrap().push(signal); + if signal == self.dies_on { + self.alive.store(false, Ordering::SeqCst); + } + } + + fn is_alive(&self, _pid: u32) -> bool { + self.alive.load(Ordering::SeqCst) + } +} + +/// Record a session for `pid`, including the pid file the app writes. +fn record(root: &Utf8Path, pid: u32) -> Session { + let dir = dir_for(root); + let session = Session { + pid, + bundle: "/derived/JP.app".into(), + configuration: "Debug".to_owned(), + workspace: root.join("workspace"), + state_dir: dir.join("state"), + user_data_dir: dir.join("data"), + stdout: Console::new(dir.join("console.out")), + stderr: Console::new(dir.join("console.err")), + trace: Console::new(dir.join("state/trace.jsonl")), + reported_footprint_mb: None, + dsym: None, + allocation_stacks: false, + }; + + session.store(&dir).unwrap(); + fs::create_dir_all(&session.state_dir).unwrap(); + fs::write(session.pid_path(), format!("{pid}\n")).unwrap(); + session +} + +fn content(outcome: jp_tool::Outcome) -> String { + match outcome { + jp_tool::Outcome::Success { content } => content, + jp_tool::Outcome::Error { message, .. } => message, + other @ jp_tool::Outcome::NeedsInput { .. } => { + panic!("unexpected outcome: {other:?}") + } + } +} + +#[test] +fn errors_without_a_recorded_session() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + + let outcome = run(root, &dir_for(root), GRACE, &RealSignals).unwrap(); + + assert_eq!( + content(outcome), + format!( + "No app session recorded at {}, so there is nothing to stop.", + Session::path(&dir_for(root)) + ) + ); +} + +/// Quitting an app that already died is not a failure, but it must say so +/// rather than claim to have stopped anything. +#[test] +fn reports_an_app_that_was_already_gone() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let session = record(root, DEAD_PID); + + let report = content(run(root, &dir_for(root), GRACE, &RealSignals).unwrap()); + + assert!( + report.starts_with( + "The app recorded as pid 4000000 was already gone. Cleared the session record.\n" + ), + "unexpected report: {report}" + ); + assert!(!Session::path(&dir_for(root)).exists()); + assert!( + session.state_dir.is_dir(), + "the state directory has to survive for a relaunch" + ); +} + +#[test] +fn stops_a_running_process_with_sigterm() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + + let session = record(root, spawn_reaped("sleep", &["30"])); + + let report = content(run(root, &dir_for(root), GRACE, &RealSignals).unwrap()); + + assert!( + report.starts_with(&format!( + "Stopped the app (pid {}) with SIGTERM.\n", + session.pid + )), + "unexpected report: {report}" + ); + assert!(!pid_is_alive(session.pid)); + assert!(!Session::path(&dir_for(root)).exists()); +} + +/// The escalation only exists for an app that ignores `SIGTERM`, so the fixture +/// has to actually ignore it. +/// A process that exits on the first signal would take the same branch as the +/// test above and prove nothing. +#[test] +fn kills_a_process_that_ignores_sigterm() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + + // The recorded pid is this process, which is alive, so the run reaches the + // ladder. Nothing is signalled for real: the fake stands in for the app. + let session = record(root, std::process::id()); + let signals = IgnoresTerm::dying_on(Signal::Kill); + + let report = content(run(root, &dir_for(root), Duration::from_millis(200), &signals).unwrap()); + + assert!( + report.starts_with(&format!( + "The app (pid {}) ignored SIGTERM and was killed.", + session.pid + )), + "unexpected report: {report}" + ); + assert_eq!(signals.sent(), vec![Signal::Term, Signal::Kill]); +} + +/// The other half of the ladder: an app that goes on `SIGTERM` must never be +/// killed. +/// Only the fake can assert the absence of that second signal. +#[test] +fn does_not_escalate_when_sigterm_is_enough() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let session = record(root, std::process::id()); + let signals = IgnoresTerm::dying_on(Signal::Term); + + let report = content(run(root, &dir_for(root), GRACE, &signals).unwrap()); + + assert!( + report.starts_with(&format!( + "Stopped the app (pid {}) with SIGTERM.\n", + session.pid + )), + "unexpected report: {report}" + ); + assert_eq!(signals.sent(), vec![Signal::Term]); +} + +/// The offsets live in the record being deleted, so the console has to be read +/// before it goes. +#[test] +fn returns_the_console_written_since_the_last_call() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let session = record(root, DEAD_PID); + fs::write( + &session.stderr.path, + "reentrant operation in its NSTableView delegate\n", + ) + .unwrap(); + + let report = content(run(root, &dir_for(root), GRACE, &RealSignals).unwrap()); + + assert!( + report.contains( + "Console (stderr), since the last call:\n\n```\nreentrant operation in its \ + NSTableView delegate\n```" + ), + "unexpected report: {report}" + ); +} + +/// Named relative to the repository, not absolutely. +/// A report is meant to be pasteable into an issue, and an absolute path here +/// says whose machine produced it. +#[test] +fn names_what_survives_for_a_relaunch() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let session = record(root, DEAD_PID); + + let report = content(run(root, &dir_for(root), GRACE, &RealSignals).unwrap()); + + assert!( + report.contains( + "Kept for a relaunch with `fresh = false`:\n\n- state: `tmp/debug-app/test/state`\n- \ + user data: `tmp/debug-app/test/data`\n" + ), + "unexpected report: {report}" + ); + assert!( + !report.contains(root.as_str()), + "the report names the machine: {report}" + ); + assert!(session.state_dir.is_dir()); +} diff --git a/.config/jp/tools/src/debug_app/report.rs b/.config/jp/tools/src/debug_app/report.rs new file mode 100644 index 000000000..cde19016f --- /dev/null +++ b/.config/jp/tools/src/debug_app/report.rs @@ -0,0 +1,1473 @@ +//! Reading back what a driven session recorded about its own performance. +//! +//! Read only, and idempotent: nothing here captures, stops, or advances an +//! offset, so the same question can be asked repeatedly at different scopes +//! against the same recording. +//! In particular the app's stream is read directly rather than through the +//! session, whose offset on it is what `debug_app_snapshot` uses to report +//! deltas. +//! +//! Two tiers answer two different questions, and the views split along that +//! line. +//! +//! [`timeline`], [`spans`] and [`views`] come from the app's own intervals, +//! which exist on every run and are readable while the app is still running. +//! They lead on counts, because a count is deterministic for the same steps and +//! a millisecond is not: "148 view bodies where step 1 had 26" is something to +//! assert, write a regression test against, and verify a fix by. +//! +//! [`hotspots`], [`callgraph`] and [`allocations`] come from a finalized +//! `.trace`, so they answer for closed recordings only. +//! An open bracket has no readable bundle, and a report says that rather than +//! showing an empty table. +//! +//! Nothing here needs a session. +//! `debug_app_quit` removes that record, and every path through this module +//! answers from the slot directory alone — which is the ordinary case, not an +//! edge. +//! +//! [`allocations`]: View::Allocations +//! [`callgraph`]: View::Callgraph +//! [`hotspots`]: View::Hotspots +//! [`spans`]: View::Spans +//! [`timeline`]: View::Timeline +//! [`views`]: View::Views + +use camino::Utf8Path; +use chrono::{DateTime, Utc}; +use jp_tool::Outcome; +use xct2cli::{ + Pid, TraceBundle, + analysis::{CallgraphBuilder, CallgraphReport}, + trace::Toc, +}; + +use crate::{ + Error, Tool, + debug_app::{ + capture::{self, Recording, Target, Tier, unix_millis}, + hotspots, + marks::{self, Mark}, + session::{Session, state_dir}, + stream::{self, Counts, Interval, Tally}, + }, + util::{ + ToolResult, error, + paths::{self, Shortening, shorten_within}, + }, +}; + +/// How many rows any one table shows. +const MAX_ROWS: usize = 60; + +/// How many named frames a bundle-backed view shows when the caller names no +/// count. +const DEFAULT_TOP: usize = 25; + +/// How many of the busiest program counters to symbolicate before choosing +/// which to show. +/// +/// Far more than any view shows, because most of what an app is doing on-CPU is +/// inside the dyld shared cache, which a trace carries no symbols for. +const EXAMINED_PCS: usize = 500; + +/// Which question a report answers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum View { + /// Per-step counts, from the app's own intervals. + Timeline, + + /// Every named interval, by how often it ran. + Spans, + + /// The view bodies alone. + Views, + + /// The busiest program counters, from a finalized bundle. + Hotspots, + + /// Top functions, or the callees of one, from a finalized bundle. + Callgraph, + + /// What the process occupied, and what an allocations bundle holds. + Allocations, +} + +impl View { + /// The name a caller writes and a report prints. + const fn label(self) -> &'static str { + match self { + View::Timeline => "timeline", + View::Spans => "spans", + View::Views => "views", + View::Hotspots => "hotspots", + View::Callgraph => "callgraph", + View::Allocations => "allocations", + } + } + + /// Whether this view reads a finalized `.trace` rather than the app's own + /// stream. + const fn needs_bundle(self) -> bool { + matches!(self, View::Hotspots | View::Callgraph | View::Allocations) + } + + fn parse(name: &str) -> Result { + match name { + "timeline" => Ok(View::Timeline), + "spans" => Ok(View::Spans), + "views" => Ok(View::Views), + "hotspots" => Ok(View::Hotspots), + "callgraph" => Ok(View::Callgraph), + "allocations" => Ok(View::Allocations), + other => Err(format!( + "`view` accepts \"timeline\", \"spans\", \"views\", \"hotspots\", \"callgraph\" \ + or \"allocations\", not {other:?}." + ) + .into()), + } + } +} + +/// Everything a report was asked to narrow itself to. +#[derive(Debug, Clone, Default)] +pub(crate) struct Request { + pub view: Option, + pub recording: Option, + pub against: Option, + pub step: Option, + pub since: Option, + pub until: Option, + pub span: Option, + pub function: Option, + pub top: Option, +} + +impl Request { + /// Read the arguments a `mode: "report"` call carries. + pub(crate) fn from_tool(t: &Tool) -> Result { + Ok(Request { + view: t.opt("view")?, + recording: t.opt("recording")?, + against: t.opt("against")?, + step: t.opt("step")?, + since: t.opt("since")?, + until: t.opt("until")?, + span: t.opt("span")?, + function: t.opt("function")?, + top: t.opt("top")?, + }) + } + + /// Whether any of these arguments were given. + /// + /// What `mode: "start"` and `mode: "stop"` check, so a caller who scoped a + /// report and asked to open a bracket is told rather than quietly given a + /// recording they did not want. + pub(crate) fn is_empty(&self) -> bool { + self.view.is_none() + && self.recording.is_none() + && self.against.is_none() + && self.step.is_none() + && self.since.is_none() + && self.until.is_none() + && self.span.is_none() + && self.function.is_none() + && self.top.is_none() + } + + /// The names that were given, for an error that says which to drop. + pub(crate) fn named(&self) -> Vec<&'static str> { + [ + ("view", self.view.is_some()), + ("recording", self.recording.is_some()), + ("against", self.against.is_some()), + ("step", self.step.is_some()), + ("since", self.since.is_some()), + ("until", self.until.is_some()), + ("span", self.span.is_some()), + ("function", self.function.is_some()), + ("top", self.top.is_some()), + ] + .into_iter() + .filter_map(|(name, given)| given.then_some(name)) + .collect() + } +} + +/// A window on the timeline, in milliseconds since the epoch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Window { + from_ms: u64, + to_ms: u64, +} + +impl Window { + /// Everything a slot has ever held. + const fn everything(now_ms: u64) -> Window { + Window { + from_ms: 0, + to_ms: now_ms, + } + } + + /// The overlap of two windows, which is how scoping composes. + fn narrowed(self, other: Window) -> Window { + Window { + from_ms: self.from_ms.max(other.from_ms), + to_ms: self.to_ms.min(other.to_ms), + } + } + + const fn holds(self, at_ms: u64) -> bool { + at_ms >= self.from_ms && at_ms <= self.to_ms + } +} + +/// What a recording occupied on the timeline. +/// +/// An open bracket runs to now, which is what makes the stream-backed views +/// answer for it while it is still recording. +fn window_of(recording: &Recording, now_ms: u64) -> Window { + Window { + from_ms: recording.started_unix.saturating_mul(1000), + to_ms: recording + .stopped_unix + .map_or(now_ms, |stopped| stopped.saturating_mul(1000) + 999), + } +} + +/// Render a report. +/// +/// Every way this can go wrong is caller-correctable — a view that does not +/// exist, a recording that does not, a tier a recording lacks — so all of them +/// come back as a tool error carrying what to run next, and all of them go +/// through the same path shortening a successful report does. +pub(crate) fn run(root: &Utf8Path, dir: &Utf8Path, request: &Request) -> ToolResult { + let shortenings = paths::shortenings(root); + + match render(dir, request, &shortenings) { + Ok(report) => Ok(Outcome::Success { + content: shorten_within(&report, &shortenings), + }), + Err(e) => error(shorten_within(&e.to_string(), &shortenings)), + } +} + +/// Assemble the report a request asks for. +fn render(dir: &Utf8Path, request: &Request, shortenings: &[Shortening]) -> Result { + let view = match &request.view { + Some(name) => View::parse(name)?, + None => View::Timeline, + }; + + if let Some(problem) = misapplied(view, request) { + return Err(problem.into()); + } + + let recordings = capture::recordings(dir); + let now_ms = unix_millis(); + + let named = match &request.recording { + None => None, + Some(id) => Some(resolve(&recordings, id, "recording")?), + }; + let compared = match &request.against { + None => None, + Some(id) => Some(resolve(&recordings, id, "against")?), + }; + + let body = if view.needs_bundle() { + let recording = match named { + Some(recording) => recording, + None => sole_closed(&recordings, dir, view)?, + }; + + from_bundle(view, &recording, dir, request, shortenings)? + } else { + from_stream( + view, + dir, + request, + named.as_ref(), + compared.as_ref(), + now_ms, + )? + }; + + Ok(format!( + "{body}{}{}", + held(&recordings, dir), + next(view, request, &recordings, dir) + )) +} + +/// Why a set of arguments does not apply to a view. +/// +/// Refused loudly rather than ignored: an argument that was silently dropped +/// leaves a caller believing they scoped something. +fn misapplied(view: View, request: &Request) -> Option { + if view.needs_bundle() { + if request.step.is_some() { + return Some(format!( + "`step` scopes the app's own intervals, which `view: \"{}\"` does not read. A \ + bundle holds samples with no notion of which step caused them. Use `view: \ + \"timeline\"` or `view: \"views\"` for per-step counts.", + view.label() + )); + } + + if request.against.is_some() { + return Some(format!( + "`against` compares counts, and `view: \"{}\"` reports sample counts — which are \ + time, and so noisy between runs. Comparing two of them chases ghosts. Compare \ + `view: \"timeline\"`, `view: \"spans\"` or `view: \"views\"` instead, which \ + count work the app did rather than moments a sampler caught it.", + view.label() + )); + } + + if request.span.is_some() { + return Some(format!( + "`span` names an interval the app timed, which `view: \"{}\"` does not read. Use \ + `function` to narrow a bundle-backed view to a symbol.", + view.label() + )); + } + } else { + if request.function.is_some() { + return Some(format!( + "`function` names a symbol in the app's binary, which `view: \"{}\"` does not \ + read. Use `span` to narrow to an interval the app timed, or `view: \"hotspots\"` \ + to narrow to a symbol.", + view.label() + )); + } + + if request.top.is_some() { + return Some(format!( + "`top` bounds a bundle-backed table, and `view: \"{}\"` shows every interval it \ + found. Narrow it with `span`, `step`, or a time window instead.", + view.label() + )); + } + } + + None +} + +/// The recording an id names. +fn resolve(recordings: &[Recording], id: &str, argument: &str) -> Result { + // A suffix match, because a report abbreviates an id and the abbreviation is + // what gets pasted back into the next call. + let matched: Vec<&Recording> = recordings + .iter() + .filter(|recording| recording.id == id || recording.id.ends_with(id)) + .collect(); + + match matched.as_slice() { + [recording] => Ok((*recording).clone()), + [] if recordings.is_empty() => Err(format!( + "`{argument}` names `{id}`, and this slot holds no recordings at all. Open one with \ + `mode: \"start\"`, drive the operation, and close it with `mode: \"stop\"`." + ) + .into()), + [] => Err(format!( + "`{argument}` names `{id}`, which no recording in this slot matches. It holds: {}.", + ids(recordings) + ) + .into()), + many => Err(format!( + "`{argument}` names `{id}`, which {} recordings match: {}. Name more of the id.", + many.len(), + many.iter() + .map(|recording| format!("`{}`", recording.id)) + .collect::>() + .join(", ") + ) + .into()), + } +} + +/// The one closed recording a bundle-backed view can answer from, when the +/// caller named none. +fn sole_closed(recordings: &[Recording], dir: &Utf8Path, view: View) -> Result { + let closed: Vec<&Recording> = recordings + .iter() + .filter(|recording| !recording.is_pending(dir) && recording.bundle(dir).exists()) + .collect(); + + match closed.as_slice() { + [recording] => Ok((*recording).clone()), + [] => Err(no_readable_bundle(recordings, dir, view)), + many => Err(format!( + "`view: \"{}\"` reads one recording, and this slot holds {} with a readable bundle: \ + {}. Name one with `recording`.", + view.label(), + many.len(), + many.iter() + .map(|recording| format!("`{}`", recording.id)) + .collect::>() + .join(", ") + ) + .into()), + } +} + +/// Why there is no bundle to read, and what to run instead. +fn no_readable_bundle(recordings: &[Recording], dir: &Utf8Path, view: View) -> Error { + let open: Vec<&Recording> = recordings + .iter() + .filter(|recording| recording.is_pending(dir)) + .collect(); + + if let [recording] = open.as_slice() { + return format!( + "`view: \"{}\"` reads a finalized `.trace`, and the only recording in this slot \ + (`{}`) is still open. Close it with `mode: \"stop\"` first. Until then, `view: \ + \"timeline\"`, `view: \"spans\"` and `view: \"views\"` answer from the app's own \ + intervals and work while it records.", + view.label(), + recording.id + ) + .into(); + } + + if recordings.is_empty() { + return format!( + "`view: \"{}\"` reads a finalized `.trace`, and this slot holds no recordings. Open \ + one with `mode: \"start\"`, drive the operation in question, and close it with \ + `mode: \"stop\"`.", + view.label() + ) + .into(); + } + + format!( + "`view: \"{}\"` reads a finalized `.trace`, and none of this slot's recordings ({}) has a \ + readable bundle. A recording made with no app running covers every process on the \ + machine, so its bundle embeds every process's environment and is destroyed as soon as it \ + is read — only the summary survives. Open the next bracket against a running app.", + view.label(), + ids(recordings) + ) + .into() +} + +/// Every recording's id, as a phrase. +fn ids(recordings: &[Recording]) -> String { + recordings + .iter() + .map(|recording| format!("`{}`", recording.id)) + .collect::>() + .join(", ") +} + +/// Answer from the app's own intervals. +fn from_stream( + view: View, + dir: &Utf8Path, + request: &Request, + named: Option<&Recording>, + compared: Option<&Recording>, + now_ms: u64, +) -> Result { + if !stream::is_present(dir) { + return Err(stream::missing(dir)); + } + + let intervals = stream::load(dir); + let all_marks = marks::load(dir); + let window = scoped(request, named, &all_marks, now_ms)?; + + let selected = select(&intervals, window, view, request.span.as_deref()); + let steps = steps_in(&all_marks, named, window, request.step); + + let Some(compared) = compared else { + return Ok(match view { + View::Timeline => render_timeline(&selected, &steps, window, dir), + View::Spans | View::Views => render_tally(view, &selected, request.span.as_deref()), + _ => unreachable!("bundle-backed views do not reach the stream"), + }); + }; + + let against_window = window_of(compared, now_ms); + let against_selected = select(&intervals, against_window, view, request.span.as_deref()); + let against_steps = steps_in(&all_marks, Some(compared), against_window, request.step); + + Ok(match view { + View::Timeline => compare_timeline( + (&selected, &steps), + (&against_selected, &against_steps), + compared, + ), + View::Spans | View::Views => { + compare_tally(view, &selected, &against_selected, compared, dir) + } + _ => unreachable!("bundle-backed views do not reach the stream"), + }) +} + +/// The window a request narrows to. +fn scoped( + request: &Request, + named: Option<&Recording>, + all_marks: &[Mark], + now_ms: u64, +) -> Result { + let mut window = Window::everything(now_ms); + + if let Some(recording) = named { + window = window.narrowed(window_of(recording, now_ms)); + } + + if let Some(step) = request.step { + let run = run_marks(all_marks, named, window); + let Some(mark) = run.iter().find(|mark| mark.step == step) else { + return Err(no_such_step(step, &run).into()); + }; + + window = window.narrowed(Window { + from_ms: mark.began_ms, + to_ms: mark.ended_ms, + }); + } + + if let Some(since) = &request.since { + window = window.narrowed(Window { + from_ms: instant(since, "since", now_ms)?, + to_ms: now_ms, + }); + } + if let Some(until) = &request.until { + window = window.narrowed(Window { + from_ms: 0, + to_ms: instant(until, "until", now_ms)?, + }); + } + + Ok(window) +} + +/// Why a step number names nothing. +fn no_such_step(step: usize, run: &[Mark]) -> String { + if run.is_empty() { + return format!( + "`step: {step}` names a driven step, and nothing has been driven in this slot. Run \ + `debug_app_drive`, which records when each step ran, and then ask again." + ); + } + + format!( + "`step: {step}` names nothing in `{}`, which has {}: {}.", + run[0].run, + run.len(), + run.iter() + .map(|mark| mark.step.to_string()) + .collect::>() + .join(", ") + ) +} + +/// A moment, as either a duration back from now or an absolute timestamp. +fn instant(raw: &str, argument: &str, now_ms: u64) -> Result { + if let Some(back_ms) = duration_ms(raw) { + return Ok(now_ms.saturating_sub(back_ms)); + } + + if let Ok(parsed) = DateTime::parse_from_rfc3339(raw) { + return u64::try_from(parsed.timestamp_millis()) + .map_err(|_| format!("`{argument}` names {raw:?}, which is before the epoch.").into()); + } + + Err(format!( + "`{argument}` accepts a duration back from now (`30s`, `5m`, `2h`) or an RFC 3339 \ + timestamp (`{}`), not {raw:?}.", + Utc::now().to_rfc3339() + ) + .into()) +} + +/// A duration like `30s`, in milliseconds. +fn duration_ms(raw: &str) -> Option { + let (digits, unit) = raw.split_at(raw.len().checked_sub(1)?); + let count: u64 = digits.parse().ok()?; + + let multiplier = match unit { + "s" => 1_000, + "m" => 60 * 1_000, + "h" => 60 * 60 * 1_000, + _ => return None, + }; + + Some(count.saturating_mul(multiplier)) +} + +/// The intervals a view reads, inside `window`. +fn select<'a>( + intervals: &'a [Interval], + window: Window, + view: View, + span: Option<&str>, +) -> Vec<&'a Interval> { + intervals + .iter() + .filter(|interval| window.holds(interval.started_ms)) + .filter(|interval| view != View::Views || interval.is_view_body()) + .filter(|interval| span.is_none_or(|name| interval.name.contains(name))) + .collect() +} + +/// The marks belonging to the run a scope selects. +/// +/// A named recording picks the run it overlaps; otherwise the most recent run, +/// which is what a caller asking about "the drive I just did" means. +fn run_marks(all_marks: &[Mark], named: Option<&Recording>, window: Window) -> Vec { + let Some(_) = named else { + return marks::latest_run(all_marks); + }; + + marks::overlapping(all_marks, window.from_ms, window.to_ms) +} + +/// The steps a report shows rows for. +fn steps_in( + all_marks: &[Mark], + named: Option<&Recording>, + window: Window, + step: Option, +) -> Vec { + let run = run_marks(all_marks, named, window); + + match step { + None => run + .into_iter() + .filter(|mark| mark.began_ms <= window.to_ms && mark.ended_ms >= window.from_ms) + .collect(), + Some(step) => run.into_iter().filter(|mark| mark.step == step).collect(), + } +} + +/// Render the per-step table. +fn render_timeline( + selected: &[&Interval], + steps: &[Mark], + window: Window, + dir: &Utf8Path, +) -> String { + if steps.is_empty() { + let counts = stream::count(selected); + + return format!( + "No driven steps fall in this window, so there is nothing to attribute per step. The \ + window holds {} {}: {} traced, {} view {}, {} FFI {}.\n\nOnly `debug_app_drive` \ + records when a step ran, and its record lives at `{}`. A window covering work done \ + by hand — clicking the app, or its own launch — has no steps in it by \ + construction.\n\nAsk `view: \"spans\"` for what ran instead.\n", + counts.intervals, + plural(counts.intervals, "interval"), + stream::millis_label(counts.traced_ms), + counts.view_bodies, + plural(counts.view_bodies, "body"), + counts.ffi_calls, + plural(counts.ffi_calls, "call"), + marks::path(dir), + ); + } + + let rows: Vec<(Mark, Counts)> = steps + .iter() + .map(|mark| { + let held: Vec<&Interval> = selected + .iter() + .copied() + .filter(|interval| mark.holds(interval.started_ms)) + .collect(); + + (mark.clone(), stream::count(&held)) + }) + .collect(); + + let mut out = format!( + "`{}`: {}, from the app's own intervals.\n\n", + rows[0].0.run, + headline(rows.len()) + ); + + out.push_str("| # | Step | Traced | View bodies | FFI calls | Footprint |\n"); + out.push_str("| -: | :--- | ---: | ---: | ---: | ---: |\n"); + + for (mark, counts) in rows.iter().take(MAX_ROWS) { + out.push_str(&format!( + "| {} | {} | {} | {} | {} | {} |\n", + mark.step, + cell(&mark.label), + stream::millis_label(counts.traced_ms), + counts.view_bodies, + counts.ffi_calls, + footprint(counts.footprint_mb), + )); + } + + if rows.len() > MAX_ROWS { + out.push_str(&format!( + "\n[{} more steps, not shown. Narrow with `step` or a time window.]\n", + rows.len() - MAX_ROWS + )); + } + + if let Some(reading) = interpret(&rows) { + out.push_str(&format!("\n{reading}\n")); + } + + if window.from_ms == 0 { + out.push_str( + "\nThe `View bodies` column counts the two view bodies the app instruments, not the \ + whole view tree.\n", + ); + } + + out +} + +/// How a timeline opens, given how many steps it covers. +fn headline(steps: usize) -> String { + if steps == 1 { + return "One step".to_owned(); + } + + format!("{steps} steps") +} + +/// What the numbers show, when they show something. +/// +/// Says nothing rather than inventing a narrative. +/// An agent reading a table may not notice that one column is flat while +/// another doubles, but a report that guessed at a pattern would be worse than +/// one that stayed quiet. +fn interpret(rows: &[(Mark, Counts)]) -> Option { + if rows.len() < 3 { + return None; + } + + let first = &rows[0].1; + let last = &rows[rows.len() - 1].1; + + let ffi_flat = rows + .iter() + .all(|(_, counts)| counts.ffi_calls == first.ffi_calls); + let bodies_grow = + last.view_bodies >= first.view_bodies.saturating_mul(2) && first.view_bodies > 0; + + if bodies_grow && ffi_flat { + return Some(format!( + "View-body count grows from {} to {} across these steps while FFI calls stay at {}. \ + The cost is re-evaluation, not loading.", + first.view_bodies, last.view_bodies, first.ffi_calls + )); + } + + let climbing = + rows.windows(2).all( + |pair| match (pair[0].1.footprint_mb, pair[1].1.footprint_mb) { + (Some(before), Some(after)) => after >= before, + _ => false, + }, + ); + let grew = match (first.footprint_mb, last.footprint_mb) { + (Some(before), Some(after)) => after.saturating_sub(before), + _ => 0, + }; + + if climbing && grew >= 20 { + return Some(format!( + "Footprint climbs {grew} MB across these steps and never falls." + )); + } + + None +} + +/// Render the `spans` or `views` table. +fn render_tally(view: View, selected: &[&Interval], span: Option<&str>) -> String { + let tallied = stream::tally(selected); + + if tallied.is_empty() { + return match span { + Some(name) => format!( + "No interval in this window is named like `{name}`. Drop `span` to see what the \ + app timed.\n" + ), + None => "The app timed nothing in this window.\n".to_owned(), + }; + } + + let subject = if view == View::Views { + "view bodies" + } else { + "intervals" + }; + let total: usize = tallied.iter().map(|(_, tally)| tally.count).sum(); + + let mut out = format!( + "{total} {subject} in this window, over {} {}.\n\n", + tallied.len(), + plural(tallied.len(), "name") + ); + + out.push_str("| ran | name | total | mean | slowest |\n| ---: | :--- | ---: | ---: | ---: |\n"); + for (name, tally) in tallied.iter().take(MAX_ROWS) { + out.push_str(&format!( + "| {} | `{}` | {} | {} | {} |\n", + tally.count, + cell(name), + stream::millis_label(tally.total_ms), + stream::millis_label(tally.mean_ms()), + stream::millis_label(tally.max_ms), + )); + } + + if tallied.len() > MAX_ROWS { + out.push_str(&format!( + "\n[{} more names, not shown. Narrow with `span`.]\n", + tallied.len() - MAX_ROWS + )); + } + + out +} + +/// Render two timelines as deltas. +fn compare_timeline( + now: (&[&Interval], &[Mark]), + against: (&[&Interval], &[Mark]), + compared: &Recording, +) -> String { + let (selected, steps) = now; + let (against_selected, against_steps) = against; + + if steps.is_empty() || against_steps.is_empty() { + return format!( + "Nothing to compare: {} steps in this window and {} in `{}`. A comparison needs a \ + driven run on both sides.\n", + steps.len(), + against_steps.len(), + compared.id + ); + } + + let mut out = format!( + "`{}`: {} here against {} in `{}`, on counts. Wall clock is left out: it is not \ + comparable between runs.\n\n", + steps[0].run, + headline(steps.len()), + against_steps.len(), + compared.id + ); + + out.push_str("| # | Step | View bodies | Δ | FFI calls | Δ |\n"); + out.push_str("| -: | :--- | ---: | ---: | ---: | ---: |\n"); + + for mark in steps.iter().take(MAX_ROWS) { + let here = counts_for(selected, mark); + let there = against_steps + .iter() + .find(|other| other.step == mark.step) + .map(|other| counts_for(against_selected, other)); + + let (bodies, calls) = match &there { + Some(there) => ( + delta(here.view_bodies, there.view_bodies), + delta(here.ffi_calls, there.ffi_calls), + ), + None => ("(absent)".to_owned(), "(absent)".to_owned()), + }; + + out.push_str(&format!( + "| {} | {} | {} | {} | {} | {} |\n", + mark.step, + cell(&mark.label), + here.view_bodies, + bodies, + here.ffi_calls, + calls, + )); + } + + if steps.len() > MAX_ROWS { + out.push_str(&format!( + "\n[{} more steps, not shown.]\n", + steps.len() - MAX_ROWS + )); + } + + if steps.len() != against_steps.len() { + out.push_str( + "\nThe two runs drove different numbers of steps, so a step number does not \ + necessarily name the same action on both sides. Check the labels.\n", + ); + } + + out +} + +/// The counts inside one step's window. +fn counts_for(selected: &[&Interval], mark: &Mark) -> Counts { + let held: Vec<&Interval> = selected + .iter() + .copied() + .filter(|interval| mark.holds(interval.started_ms)) + .collect(); + + stream::count(&held) +} + +/// Render two tallies as deltas. +fn compare_tally( + view: View, + selected: &[&Interval], + against_selected: &[&Interval], + compared: &Recording, + dir: &Utf8Path, +) -> String { + let here = stream::tally(selected); + let there: Vec<(String, Tally)> = stream::tally(against_selected); + + if here.is_empty() && there.is_empty() { + return format!( + "Neither window holds anything the app timed. `{}` is at `{}` if you want to check \ + what was recorded.\n", + compared.id, + state_dir(dir) + ); + } + + let subject = if view == View::Views { + "view bodies" + } else { + "intervals" + }; + + let mut out = format!( + "{subject} here against `{}`, on how often each ran.\n\n", + compared.id + ); + + out.push_str("| name | ran | in `against` | Δ |\n| :--- | ---: | ---: | ---: |\n"); + + let mut names: Vec = here.iter().map(|(name, _)| name.clone()).collect(); + let only_there: Vec = there + .iter() + .map(|(name, _)| name.clone()) + .filter(|name| !names.contains(name)) + .collect(); + names.extend(only_there); + + for name in names.iter().take(MAX_ROWS) { + let ours = here + .iter() + .find(|(other, _)| other == name) + .map_or(0, |(_, tally)| tally.count); + let theirs = there + .iter() + .find(|(other, _)| other == name) + .map_or(0, |(_, tally)| tally.count); + + out.push_str(&format!( + "| `{}` | {ours} | {theirs} | {} |\n", + cell(name), + delta(ours, theirs) + )); + } + + if names.len() > MAX_ROWS { + out.push_str(&format!( + "\n[{} more names, not shown.]\n", + names.len() - MAX_ROWS + )); + } + + out +} + +/// Answer from a finalized bundle. +fn from_bundle( + view: View, + recording: &Recording, + dir: &Utf8Path, + request: &Request, + shortenings: &[Shortening], +) -> Result { + if recording.is_pending(dir) { + return Err(format!( + "`{}` is still open, so its bundle is not finalized and nothing can read it: \ + `xctrace` writes a bundle out on its way to exiting. Close it with `mode: \"stop\"`, \ + or ask `view: \"timeline\"`, which reads the app's own intervals and answers while a \ + bracket records.", + recording.id + ) + .into()); + } + + if !recording.bundle(dir).exists() { + return Err(bundle_gone(recording, view).into()); + } + + let Some(target) = target_for(recording, dir) else { + return Err(format!( + "`{}` has nothing to attribute its samples to: no app was recorded with it, and this \ + slot's session record is gone. Open the next bracket against a running app, which is \ + what writes the binary, dSYM and load address a symbol needs.", + recording.id + ) + .into()); + }; + + let bundle = TraceBundle::open(recording.bundle(dir).as_std_path())?; + let top = request.top.unwrap_or(DEFAULT_TOP); + + let mut out = format!( + "`{}` ({}, {}), {}.\n\n", + recording.id, + if recording.scope.is_system() { + "every process on the machine" + } else { + "the app alone" + }, + recording.describe(), + view.label() + ); + + match view { + View::Hotspots => { + let report = + hotspots::read_hotspots(&bundle, &target, request.function.clone(), EXAMINED_PCS)?; + out.push_str(&hotspots::render_hotspots(&report, shortenings, top)); + } + View::Callgraph => out.push_str(&render_callgraph( + &CallgraphBuilder::new(&bundle) + .pid(Pid::new(i64::from(target.pid))) + .binary(Some(target.binary.as_std_path().to_owned())) + .dsym(target.dsym.as_ref().map(|p| p.as_std_path().to_owned())) + .slide(hotspots::slide_mode(&target)) + .function(request.function.clone()) + .top(top) + .run()?, + )), + View::Allocations => out.push_str(&render_allocations(&bundle, recording, dir)), + _ => unreachable!("stream-backed views do not reach the bundle"), + } + + Ok(out) +} + +/// Why a recording has no bundle left to read. +fn bundle_gone(recording: &Recording, view: View) -> String { + if recording.scope.is_system() { + return format!( + "`{}` was recorded with no app running, so it covers every process on the machine — \ + its bundle embeds every one of their environments and was destroyed as soon as it \ + was read. Only the summary survives, at `{}.md` under this slot's `profiles/`. \ + Record the next bracket against a running app for `view: \"{}\"` to have something \ + to read.", + recording.id, + recording.id, + view.label() + ); + } + + format!( + "`{}` no longer has a bundle. A retained bundle is bounded by age and by a byte budget, \ + and this one has been reclaimed; its summary is kept at `{}.md` under this slot's \ + `profiles/`. Record a new bracket for `view: \"{}\"`.", + recording.id, + recording.id, + view.label() + ) +} + +/// What a recording is attributed to. +/// +/// The recording's own record first, which is what makes reading it work after +/// `debug_app_quit`. +/// A live session covers a record written before the field existed. +fn target_for(recording: &Recording, dir: &Utf8Path) -> Option { + recording.target.clone().or_else(|| { + Session::load(dir) + .ok() + .flatten() + .as_ref() + .map(Target::for_session) + }) +} + +/// Render the top functions, or the callees of one. +fn render_callgraph(report: &CallgraphReport) -> String { + if report.stats.is_empty() { + return format!( + "No stacks matched: {} ({} samples).\n", + report.view, report.total_samples + ); + } + + let mut out = format!( + "{} — {} samples carried a stack.\n\n", + report.view, report.total_samples + ); + + out.push_str("| samples | share | function |\n| ---: | ---: | :--- |\n"); + for stat in &report.stats { + out.push_str(&format!( + "| {} | {:.1}% | `{}` |\n", + stat.samples, + stat.fraction * 100.0, + symbol(&stat.function) + )); + } + + out.push_str(&explain_callgraph(report)); + out +} + +/// What a callgraph table does and does not say. +/// +/// Two things about it mislead on sight. +/// A run of identical percentages looks like a bug and is not, and a column of +/// bare addresses looks like broken symbolication and usually is not. +fn explain_callgraph(report: &CallgraphReport) -> String { + let mut notes = Vec::new(); + + let tied = report.stats.first().is_some_and(|first| { + report + .stats + .iter() + .filter(|s| s.samples == first.samples) + .count() + > 2 + }); + if tied { + notes.push( + "Counting is inclusive: a function counts once for every stack it appears anywhere \ + in. Frames that share one call chain therefore share one count, which is why a run \ + of rows can be identical — they are the chain every sample passed through, not \ + several equally expensive functions.", + ); + } + + let unnamed = report + .stats + .iter() + .filter(|stat| stat.function.starts_with("0x")) + .count(); + if unnamed > 0 { + notes.push( + "A bare address is a frame in code the trace carries no symbols for, which is most of \ + the system: only the app's own binary can be named here.", + ); + } + + if notes.is_empty() { + return String::new(); + } + + format!("\n{}\n", notes.join("\n\n")) +} + +/// Any table schema naming allocation data, alongside every schema the bundle +/// holds. +/// +/// Matched on a substring rather than on a known name, because there is no +/// known name: `xctrace export` surfaces none today, and a future Xcode that +/// starts surfacing them should be noticed rather than reported as absent. +/// +/// Best-effort. +/// A bundle whose table of contents will not open costs this view its least +/// important paragraph, and the footprint it exists to report comes from the +/// app's own stream rather than from the bundle at all. +fn allocation_tables(bundle: &TraceBundle) -> (Vec, Vec) { + let mut tables: Vec = bundle + .toc() + .ok() + .as_ref() + .and_then(Toc::first_run) + .map(|run| run.tables.iter().map(|t| t.schema.clone()).collect()) + .unwrap_or_default(); + tables.sort_unstable(); + tables.dedup(); + + let allocation = tables + .iter() + .filter(|schema| schema.contains("alloc")) + .cloned() + .collect(); + + (tables, allocation) +} + +/// Render what a recording can say about memory. +/// +/// The footprint the app measured for itself, which every run records and which +/// is the number macOS judges a process by. +/// Per-call-site attribution is absent because it is not reachable: the +/// Allocations instrument writes to the trace event store rather than to a +/// table, and `xctrace export` surfaces none of it, so the only reader is +/// Instruments itself. +fn render_allocations(bundle: &TraceBundle, recording: &Recording, dir: &Utf8Path) -> String { + let (tables, allocation) = allocation_tables(bundle); + + let window = window_of(recording, unix_millis()); + let intervals = stream::load(dir); + let sampled: Vec<&Interval> = intervals + .iter() + .filter(|interval| window.holds(interval.started_ms)) + .filter(|interval| interval.footprint_mb.is_some()) + .collect(); + + let mut out = String::new(); + + match (sampled.first(), sampled.last()) { + (Some(first), Some(last)) => { + let before = first.footprint_mb.unwrap_or_default(); + let after = last.footprint_mb.unwrap_or_default(); + let peak = sampled + .iter() + .filter_map(|interval| interval.footprint_mb) + .max() + .unwrap_or_default(); + + out.push_str(&format!( + "Footprint over this recording: {before} MB at `{}`, {after} MB at `{}`, peaking \ + at {peak} MB, over {} samples the app took of itself.\n\n", + first.name, + last.name, + sampled.len() + )); + + if peak > after { + out.push_str(&format!( + "The peak sits {} MB above where it ended, so that much was transient rather \ + than retained. Driving the same selections twice tells a high-water mark \ + from a leak: a mark plateaus on the second visit, a leak keeps climbing.\n\n", + peak - after + )); + } + } + _ => out.push_str( + "The app sampled its own footprint nowhere in this recording's window, so there is no \ + memory trajectory to show.\n\n", + ), + } + + if allocation.is_empty() { + out.push_str(&per_call_site_note(recording, dir)); + } else { + out.push_str(&format!( + "This bundle carries allocation tables ({}), which `xctrace export` has not surfaced \ + before — worth reading into a real per-call-site view.\n", + allocation.join(", ") + )); + } + + if !tables.is_empty() { + out.push_str(&format!("\nTables in the bundle: {}.\n", tables.join(", "))); + } + + out +} + +/// Why there is no table of allocations by call site, and what to do instead. +fn per_call_site_note(recording: &Recording, dir: &Utf8Path) -> String { + if !recording.holds(Tier::Allocations) { + return format!( + "`{}` recorded {}, so it holds no allocation stacks. The footprint above needs none — \ + the app samples it on every run. For stacks, record a bracket with `capture: \ + [\"allocations\"]` against an app launched with `allocation_stacks: true`, and read \ + what comes back: it is a bundle for Instruments rather than a table.\n", + recording.id, + recording.describe() + ); + } + + format!( + "Allocation stacks were recorded, and cannot be read from here. The Allocations \ + instrument writes to the trace event store rather than to a table, and `xctrace export` \ + surfaces none of it — Apple's position is that Leaks and Allocations are built on a \ + different recording technology. So this is not a missing analysis: there is nothing on \ + the command line to analyse.\n\nOpen `{}` in Instruments for the call tree. It is kept \ + for exactly that, and it is not to be committed or attached to a bug report.\n", + recording.bundle(dir) + ) +} + +/// What this slot is holding, when it holds anything. +fn held(recordings: &[Recording], dir: &Utf8Path) -> String { + if recordings.is_empty() { + return String::new(); + } + + let mut out = String::from("\n## Recordings in this slot\n\n"); + out.push_str( + "| id | scope | recorded | state | bundle |\n| :--- | :--- | :--- | :--- | :--- |\n", + ); + + for recording in recordings { + out.push_str(&format!( + "| `{}` | {} | {} | {} | {} |\n", + recording.id, + if recording.scope.is_system() { + "system" + } else { + "attach" + }, + recording.describe(), + if recording.is_pending(dir) { + "open" + } else { + "closed" + }, + if recording.bundle(dir).exists() { + "kept" + } else { + "gone" + }, + )); + } + + out +} + +/// The calls that answer the questions this report raises. +/// +/// Named on every report, because an agent that does not know a view exists +/// will not ask for it. +fn next(view: View, request: &Request, recordings: &[Recording], dir: &Utf8Path) -> String { + let mut lines: Vec = Vec::new(); + let readable: Vec<&Recording> = recordings + .iter() + .filter(|recording| !recording.is_pending(dir) && recording.bundle(dir).exists()) + .collect(); + + match view { + View::Timeline => { + if request.step.is_none() { + lines.push( + "view=`views` step=`` — which bodies ran under one step, and how often" + .to_owned(), + ); + } + lines.push( + "view=`spans` — every interval the app timed, by how often it ran".to_owned(), + ); + } + View::Spans => { + lines.push("view=`views` — the view bodies alone".to_owned()); + lines.push( + "view=`timeline` — the same intervals, attributed per driven step".to_owned(), + ); + } + View::Views => { + lines.push("view=`spans` — every interval, not only the view bodies".to_owned()); + lines.push("view=`timeline` — the same counts, attributed per driven step".to_owned()); + } + View::Hotspots => { + lines.push( + "view=`callgraph` function=`` — what that function was calling".to_owned(), + ); + lines.push("view=`timeline` — what each driven step cost, in counts".to_owned()); + } + View::Callgraph => { + lines.push( + "view=`hotspots` — the busiest program counters, with source sites".to_owned(), + ); + } + View::Allocations => { + lines.push("view=`hotspots` — where the time went in the same recording".to_owned()); + } + } + + // Named whenever there is a bundle to read, because a stream-backed view says + // which step is expensive and only a bundle-backed one says which code is. + if !view.needs_bundle() + && let Some(recording) = readable.last() + { + lines.push(format!( + "view=`hotspots` recording=`{}` — the code the samples landed in", + recording.id + )); + } + + if request.against.is_none() + && let [first, .., last] = readable.as_slice() + { + lines.push(format!( + "view=`timeline` recording=`{}` against=`{}` — the two compared on counts", + last.id, first.id + )); + } + + if readable.is_empty() && !view.needs_bundle() { + lines.push( + "`mode: \"start\"`, drive the operation, `mode: \"stop\"` — then `view: \"hotspots\"` \ + can name the code responsible" + .to_owned(), + ); + } + + format!( + "\n## Next\n\n{}\n", + lines + .iter() + .map(|line| format!("- {line}")) + .collect::>() + .join("\n") + ) +} + +/// A footprint, or a dash when nothing was sampled. +fn footprint(mb: Option) -> String { + mb.map_or_else(|| "—".to_owned(), |mb| format!("{mb} MB")) +} + +/// A difference between two counts, signed, or a dash when there is none. +fn delta(here: usize, there: usize) -> String { + let change = here.cast_signed() - there.cast_signed(); + if change == 0 { + return "—".to_owned(); + } + + format!("{change:+}") +} + +/// `text` as a table cell: escaped, and short enough not to wrap the table. +fn cell(text: &str) -> String { + let escaped = text.replace('|', "\\|"); + if escaped.chars().count() <= 60 { + return escaped; + } + + let kept: String = escaped.chars().take(59).collect(); + format!("{kept}…") +} + +/// A symbol name as a table cell. +/// +/// Escaped but never shortened. +/// A demangled Rust or Swift name routinely runs past a hundred characters, and +/// the generic parameters that make it long are also what distinguish it from +/// its neighbours — a truncated one names a function nobody can look up. +fn symbol(name: &str) -> String { + name.replace('|', "\\|") +} + +/// `word` pluralized for `count`. +fn plural(count: usize, word: &str) -> String { + match (count, word) { + (1, word) => word.to_owned(), + (_, "body") => "bodies".to_owned(), + (_, word) => format!("{word}s"), + } +} + +#[cfg(test)] +#[path = "report_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/report_tests.rs b/.config/jp/tools/src/debug_app/report_tests.rs new file mode 100644 index 000000000..5fd6ecd78 --- /dev/null +++ b/.config/jp/tools/src/debug_app/report_tests.rs @@ -0,0 +1,739 @@ +use std::fs; + +use camino::{Utf8Path, Utf8PathBuf}; +use chrono::DateTime; +use jp_tool::Outcome; +use serde_json::{Value, json}; + +use super::{Request, View, duration_ms, instant, run}; +use crate::debug_app::{ + capture::{self, Recording, Scope, Target, Tier, profiles_dir}, + marks::{self, Mark}, + session::{Session, Slot, state_dir}, +}; + +/// `2020-01-01T00:00:00Z`, in milliseconds. +/// +/// Fixed and comfortably in the past, so every window a report derives from the +/// current clock holds the whole fixture whenever the suite runs. +const BASE_MS: u64 = 1_577_836_800_000; + +/// Where an earlier run sits, far enough back that the two windows cannot +/// overlap. +const EARLIER_MS: u64 = BASE_MS - 800_000; + +/// A slot every test in this file shares, so paths are predictable. +fn dir_for(root: &Utf8Path) -> Utf8PathBuf { + Session::dir(root, &Slot::fixed("test")) +} + +/// What a report said, whichever way it went. +/// +/// Every failure this tool has is a caller-correctable one, so it comes back as +/// an `Outcome::Error` rather than as a `Result::Err`. +fn content(outcome: Outcome) -> String { + match outcome { + Outcome::Success { content } => content, + Outcome::Error { message, .. } => message, + other @ Outcome::NeedsInput { .. } => panic!("unexpected outcome: {other:?}"), + } +} + +fn reported(root: &Utf8Path, request: &Request) -> String { + content(run(root, &dir_for(root), request).unwrap()) +} + +/// `at_ms` as the app spells a timestamp. +fn stamp(at_ms: u64) -> String { + let time = DateTime::from_timestamp_millis(at_ms.cast_signed()).unwrap(); + + format!("{}000Z", time.format("%Y-%m-%dT%H:%M:%S%.3f")) +} + +/// One interval the app timed. +fn interval(at_ms: u64, target: &str, name: &str, duration_ms: f64, footprint_mb: u64) -> Value { + json!({ + "timestamp": stamp(at_ms), + "level": "INFO", + "target": target, + "fields": { + "message": name, + "duration_ms": duration_ms, + "footprint_mb": footprint_mb, + }, + }) +} + +/// The same, nested inside the interval that caused it. +fn nested(at_ms: u64, target: &str, name: &str, duration_ms: f64, span: &str) -> Value { + json!({ + "timestamp": stamp(at_ms), + "level": "INFO", + "target": target, + "fields": { "message": name, "duration_ms": duration_ms }, + "spans": [{ "name": span }], + }) +} + +/// One selection: an outer interval, one FFI call under it, and `bodies` view +/// bodies afterwards, all inside the step window opening at `at_ms`. +fn selection(at_ms: u64, select_ms: f64, bodies: usize, footprint_mb: u64) -> Vec { + let mut out = vec![ + interval( + at_ms + 100, + "JP.App", + "conversation.select", + select_ms, + footprint_mb, + ), + nested( + at_ms + 120, + "JP.FFI", + "storage.read", + 8.0, + "conversation.select", + ), + ]; + + for index in 0..bodies { + out.push(interval( + at_ms + 200 + index as u64 * 10, + "JP.App", + "ConversationHistoryView.body", + 0.4, + footprint_mb + 1, + )); + } + + out +} + +fn mark(run: &str, step: usize, at_ms: u64, row: &str) -> Mark { + Mark { + run: run.to_owned(), + step, + label: format!("select {{\"identifier\":\"sidebar.row.{row}\"}}"), + began_ms: at_ms, + ended_ms: at_ms + 500, + } +} + +fn write_stream(path: &Utf8Path, lines: &[Value]) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + path, + format!( + "{}\n", + lines + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + ), + ) + .unwrap(); +} + +/// A slot holding one driven run of three selections, whose view-body count +/// doubles while its FFI calls stay flat. +fn driven(root: &Utf8Path) -> Utf8PathBuf { + let dir = dir_for(root); + + let mut lines = selection(BASE_MS, 40.0, 2, 180); + lines.extend(selection(BASE_MS + 1_000, 60.0, 4, 190)); + lines.extend(selection(BASE_MS + 2_000, 90.0, 8, 204)); + write_stream(&state_dir(&dir).join("trace.jsonl"), &lines); + + marks::append(&dir, &[ + mark("drive-1", 1, BASE_MS, "a"), + mark("drive-1", 3, BASE_MS + 1_000, "b"), + mark("drive-1", 5, BASE_MS + 2_000, "c"), + ]) + .unwrap(); + + dir +} + +/// An earlier run of the same list, archived at the launch that replaced it, +/// where every selection cost two body evaluations. +fn archived_earlier_run(dir: &Utf8Path) { + let mut lines = selection(EARLIER_MS, 40.0, 2, 150); + lines.extend(selection(EARLIER_MS + 1_000, 40.0, 2, 150)); + lines.extend(selection(EARLIER_MS + 2_000, 40.0, 2, 150)); + write_stream( + &profiles_dir(dir).join(format!("trace-{EARLIER_MS}.jsonl")), + &lines, + ); + + marks::append(dir, &[ + mark("drive-0", 1, EARLIER_MS, "a"), + mark("drive-0", 3, EARLIER_MS + 1_000, "b"), + mark("drive-0", 5, EARLIER_MS + 2_000, "c"), + ]) + .unwrap(); +} + +/// A recording covering `[from_ms, to_ms]`, with a bundle on disk. +fn recorded(dir: &Utf8Path, id: &str, tiers: Vec, from_ms: u64, to_ms: Option) { + let recording = Recording { + id: id.to_owned(), + tiers, + scope: Scope::Attach(31657), + recorder_pid: 4_000_000, + started_unix: from_ms / 1000, + stopped_unix: to_ms.map(|to_ms| to_ms / 1000), + target: Some(Target { + pid: 31657, + binary: dir.join("JP.app/Contents/MacOS/JP"), + dsym: None, + slide: None, + configuration: "Debug".to_owned(), + }), + }; + + fs::create_dir_all(recording.bundle(dir)).unwrap(); + recording.store(dir).unwrap(); +} + +/// A bracket opened just now and still open. +/// +/// Dated from the current clock rather than from the fixture, because a bracket +/// stops being pending once it is older than the window it is allowed to stay +/// open for — a recording dated 2020 reads as abandoned, not as open. +fn open_now(dir: &Utf8Path, id: &str) { + let started = capture::unix_seconds(); + + recorded(dir, id, vec![Tier::Sampling], started * 1000, None); +} + +/// Close a bracket the way a stop does, keeping what it was recording. +fn close(dir: &Utf8Path, id: &str) { + let mut recording = capture::recordings(dir) + .into_iter() + .find(|recording| recording.id == id) + .unwrap(); + let target = recording.target.clone(); + + recording.close(target, dir).unwrap(); +} + +#[test] +fn every_view_round_trips_through_its_name() { + for view in [ + View::Timeline, + View::Spans, + View::Views, + View::Hotspots, + View::Callgraph, + View::Allocations, + ] { + assert_eq!(View::parse(view.label()).unwrap(), view); + } +} + +#[test] +fn an_unknown_view_is_rejected_by_name() { + let error = View::parse("flamegraph").unwrap_err().to_string(); + + assert_eq!( + error, + "`view` accepts \"timeline\", \"spans\", \"views\", \"hotspots\", \"callgraph\" or \ + \"allocations\", not \"flamegraph\"." + ); +} + +#[test] +fn a_relative_window_counts_back_from_now() { + assert_eq!(duration_ms("30s"), Some(30_000)); + assert_eq!(duration_ms("5m"), Some(300_000)); + assert_eq!(duration_ms("2h"), Some(7_200_000)); + assert_eq!(duration_ms("30"), None); + assert_eq!(duration_ms("s"), None); + assert_eq!(instant("30s", "since", 100_000).unwrap(), 70_000); +} + +#[test] +fn an_absolute_window_is_read_as_rfc_3339() { + assert_eq!( + instant("2020-01-01T00:00:00Z", "since", 0).unwrap(), + BASE_MS + ); +} + +#[test] +fn a_window_that_is_neither_form_names_both() { + let error = instant("yesterday", "since", 0).unwrap_err().to_string(); + + assert!( + error.starts_with( + "`since` accepts a duration back from now (`30s`, `5m`, `2h`) or an RFC 3339 timestamp" + ), + "unexpected error: {error}" + ); +} + +#[test] +fn an_empty_request_names_nothing() { + assert!(Request::default().is_empty()); + assert_eq!(Request::default().named(), Vec::<&str>::new()); +} + +#[test] +fn a_request_names_what_was_given() { + let request = Request { + view: Some("views".to_owned()), + step: Some(3), + ..Request::default() + }; + + assert!(!request.is_empty()); + assert_eq!(request.named(), vec!["view", "step"]); +} + +/// The whole point of the default view: counts attributed to the step that +/// caused them, a reading of what the columns show while the pattern is clear, +/// and the calls that answer the next question. +#[test] +fn a_timeline_attributes_the_apps_intervals_to_the_steps_that_caused_them() { + let workspace = camino_tempfile::tempdir().unwrap(); + driven(workspace.path()); + + assert_eq!( + reported(workspace.path(), &Request::default()), + "`drive-1`: 3 steps, from the app's own intervals.\n\n| # | Step | Traced | View bodies | \ + FFI calls | Footprint |\n| -: | :--- | ---: | ---: | ---: | ---: |\n| 1 | select \ + {\"identifier\":\"sidebar.row.a\"} | 41 ms | 2 | 1 | 181 MB |\n| 3 | select \ + {\"identifier\":\"sidebar.row.b\"} | 62 ms | 4 | 1 | 191 MB |\n| 5 | select \ + {\"identifier\":\"sidebar.row.c\"} | 93 ms | 8 | 1 | 205 MB |\n\nView-body count grows \ + from 2 to 8 across these steps while FFI calls stay at 1. The cost is re-evaluation, not \ + loading.\n\nThe `View bodies` column counts the two view bodies the app instruments, not \ + the whole view tree.\n\n## Next\n\n- view=`views` step=`` — which bodies ran under \ + one step, and how often\n- view=`spans` — every interval the app timed, by how often it \ + ran\n- `mode: \"start\"`, drive the operation, `mode: \"stop\"` — then `view: \ + \"hotspots\"` can name the code responsible\n" + ); +} + +/// A selection's read runs on its own task, so the harness sees the sidebar +/// change and moves on while the transcript is still loading. +/// Attributing an interval to the step whose window holds its *end* therefore +/// files a slow selection under the following step — or under no step at all +/// when it outlives the run — and the table reports one step's cost against +/// another's name. +/// +/// The numbers here are the ones that exposed it: step 4's selection took +/// 48.9ms and ended 9ms after step 4's window closed; step 5's took 85.0ms and +/// ended 44ms after the whole run finished. +#[test] +fn a_selection_outliving_its_step_is_still_attributed_to_it() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let dir = dir_for(root); + + write_stream(&state_dir(&dir).join("trace.jsonl"), &[ + // Begins 85ms into step 4's window, ends 9ms after it closed — inside + // step 5's. + interval(BASE_MS + 3_208, "JP.App", "conversation.select", 48.932, 42), + // Begins 405ms into step 5's window, ends 44ms past the end of the run. + interval( + BASE_MS + 3_688, + "JP.App", + "conversation.select", + 84.992, + 101, + ), + ]); + + marks::append(&dir, &[ + Mark { + run: "drive-late".to_owned(), + step: 4, + label: "select 4".to_owned(), + began_ms: BASE_MS + 3_075, + ended_ms: BASE_MS + 3_199, + }, + Mark { + run: "drive-late".to_owned(), + step: 5, + label: "select 5".to_owned(), + began_ms: BASE_MS + 3_199, + ended_ms: BASE_MS + 3_644, + }, + ]) + .unwrap(); + + let report = reported(root, &Request::default()); + + assert!( + report.contains( + "| 4 | select 4 | 49 ms | 0 | 0 | 42 MB |\n| 5 | select 5 | 85 ms | 0 | 0 | 101 MB |\n" + ), + "unexpected report: {report}" + ); +} + +/// `debug_app_quit` removes the session record, and reading a run afterwards is +/// the ordinary case rather than an edge. +#[test] +fn a_report_after_quit_works_from_the_slot_directory_alone() { + let workspace = camino_tempfile::tempdir().unwrap(); + let dir = driven(workspace.path()); + recorded( + &dir, + "profile-1", + vec![Tier::Sampling], + BASE_MS, + Some(BASE_MS + 3_000), + ); + + assert!(!Session::path(&dir).exists()); + + let report = reported(workspace.path(), &Request::default()); + + assert!(report.starts_with("`drive-1`: 3 steps"), "{report}"); + assert!( + report.contains("| `profile-1` | attach | sampling | closed | kept |"), + "{report}" + ); +} + +/// The two tiers answer at different moments, and the difference has to be +/// visible rather than inferred: a bracket that is still recording has no +/// finalized bundle, because `xctrace` writes one out on its way to exiting. +#[test] +fn an_open_bracket_and_a_closed_one_read_differently() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let dir = driven(root); + open_now(&dir, "profile-open"); + + let while_open = reported(root, &Request::default()); + assert!( + while_open.contains("| `profile-open` | attach | sampling | open | kept |"), + "unexpected report: {while_open}" + ); + + let hotspots = Request { + view: Some("hotspots".to_owned()), + ..Request::default() + }; + let refusal = "`view: \"hotspots\"` reads a finalized `.trace`, and the only recording in \ + this slot (`profile-open`) is still open. Close it with `mode: \"stop\"` \ + first. Until then, `view: \"timeline\"`, `view: \"spans\"` and `view: \ + \"views\"` answer from the app's own intervals and work while it records."; + assert_eq!(reported(root, &hotspots), refusal); + + // Closing it is the only change. + close(&dir, "profile-open"); + + let once_closed = reported(root, &Request::default()); + assert!( + once_closed.contains("| `profile-open` | attach | sampling | closed | kept |"), + "unexpected report: {once_closed}" + ); + + // Past the open-bracket gate, so the read reaches the bundle itself. What it + // finds in there is not assertable without `xctrace`; the live end-to-end run + // is what covers that. + let read = reported(root, &hotspots); + assert!(!read.contains("is still open"), "unexpected report: {read}"); + assert_ne!(read, refusal); +} + +/// The gate order, pinned from the other side: a closed recording is checked +/// for a bundle rather than for being open, and the two say different things. +#[test] +fn a_closed_recording_whose_bundle_was_reclaimed_says_so() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let dir = driven(root); + recorded( + &dir, + "profile-1", + vec![Tier::Sampling], + BASE_MS, + Some(BASE_MS + 3_000), + ); + fs::remove_dir_all(dir.join("profiles/profile-1.trace")).unwrap(); + + assert_eq!( + reported(root, &Request { + view: Some("hotspots".to_owned()), + recording: Some("profile-1".to_owned()), + ..Request::default() + }), + "`profile-1` no longer has a bundle. A retained bundle is bounded by age and by a byte \ + budget, and this one has been reclaimed; its summary is kept at `profile-1.md` under \ + this slot's `profiles/`. Record a new bracket for `view: \"hotspots\"`." + ); +} + +/// Asking about memory is not refused for want of an instrument. +/// The footprint is the answer, the app samples it on every run, and what the +/// recording lacks is said in the body rather than instead of it. +/// +/// Reaching the bundle at all is what the tier gates, and there is nothing to +/// gate here: the Allocations instrument's data is not exportable, so a +/// recording that holds it can say no more about call sites than one that does +/// not. +#[test] +fn allocations_reports_the_footprint_and_says_what_the_recording_lacks() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let dir = driven(root); + recorded( + &dir, + "profile-1", + vec![Tier::Sampling], + BASE_MS, + Some(BASE_MS + 3_000), + ); + + let report = reported(root, &Request { + view: Some("allocations".to_owned()), + ..Request::default() + }); + + assert!( + report.contains( + "`profile-1` recorded sampling, so it holds no allocation stacks. The footprint above \ + needs none" + ), + "unexpected report: {report}" + ); +} + +/// Counts are comparable between runs and milliseconds are not, which is the +/// whole reason this view leads on them. +/// The earlier run is only reachable because its stream was archived rather +/// than truncated. +#[test] +fn comparing_two_recordings_shows_count_deltas() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let dir = driven(root); + archived_earlier_run(&dir); + + recorded( + &dir, + "profile-earlier", + vec![Tier::Sampling], + EARLIER_MS, + Some(EARLIER_MS + 3_000), + ); + recorded( + &dir, + "profile-now", + vec![Tier::Sampling], + BASE_MS, + Some(BASE_MS + 3_000), + ); + + let report = reported(root, &Request { + recording: Some("profile-now".to_owned()), + against: Some("profile-earlier".to_owned()), + ..Request::default() + }); + + assert!( + report.contains( + "| # | Step | View bodies | Δ | FFI calls | Δ |\n| -: | :--- | ---: | ---: | ---: | \ + ---: |\n| 1 | select {\"identifier\":\"sidebar.row.a\"} | 2 | — | 1 | — |\n| 3 | \ + select {\"identifier\":\"sidebar.row.b\"} | 4 | +2 | 1 | — |\n| 5 | select \ + {\"identifier\":\"sidebar.row.c\"} | 8 | +6 | 1 | — |\n" + ), + "unexpected report: {report}" + ); +} + +/// A report is pasted into issues, and a path from the machine that produced it +/// names somebody's filesystem. +/// Both the report and the refusal go through the shortening, since a refusal +/// is the more likely of the two to quote a path. +#[test] +fn no_absolute_path_reaches_the_output_on_either_path() { + let empty = camino_tempfile::tempdir().unwrap(); + let failed = reported(empty.path(), &Request::default()); + assert!( + !failed.contains(empty.path().as_str()) && !failed.contains(" /"), + "the failure names a filesystem: {failed}" + ); + + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let dir = driven(root); + archived_earlier_run(&dir); + recorded( + &dir, + "profile-1", + vec![Tier::Sampling], + BASE_MS, + Some(BASE_MS + 3_000), + ); + + for request in [ + Request::default(), + Request { + view: Some("spans".to_owned()), + ..Request::default() + }, + Request { + view: Some("views".to_owned()), + step: Some(5), + ..Request::default() + }, + Request { + // A window holding no driven steps, which is the branch that names + // where the step record lives. + since: Some("1h".to_owned()), + ..Request::default() + }, + Request { + // A slot with no such recording, which is the branch that lists them. + recording: Some("profile-absent".to_owned()), + ..Request::default() + }, + ] { + let report = reported(root, &request); + + assert!( + !report.contains(root.as_str()), + "the report names {root}: {report}" + ); + assert!( + !report.contains(" /") && !report.contains("`/"), + "the report holds an absolute path: {report}" + ); + } +} + +#[test] +fn scoping_to_one_step_narrows_the_table_to_it() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + driven(root); + + let report = reported(root, &Request { + step: Some(3), + ..Request::default() + }); + + assert!( + report.starts_with( + "`drive-1`: One step, from the app's own intervals.\n\n| # | Step | Traced | View \ + bodies | FFI calls | Footprint |\n| -: | :--- | ---: | ---: | ---: | ---: |\n| 3 | \ + select {\"identifier\":\"sidebar.row.b\"} | 62 ms | 4 | 1 | 191 MB |\n" + ), + "unexpected report: {report}" + ); + assert!(!report.contains("sidebar.row.a"), "{report}"); +} + +#[test] +fn a_step_that_names_nothing_lists_what_there_is() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + driven(root); + + assert_eq!( + reported(root, &Request { + step: Some(2), + ..Request::default() + }), + "`step: 2` names nothing in `drive-1`, which has 3: 1, 3, 5." + ); +} + +/// The app's launch, and anything done by hand, has no steps around it by +/// construction. +/// A report says so and names the view that still answers. +#[test] +fn a_window_with_no_driven_steps_names_the_view_that_still_answers() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + write_stream(&state_dir(&dir_for(root)).join("trace.jsonl"), &[interval( + BASE_MS, + "JP.App", + "app.launch", + 900.0, + 120, + )]); + + let report = reported(root, &Request::default()); + + assert!( + report.starts_with( + "No driven steps fall in this window, so there is nothing to attribute per step. The \ + window holds 1 interval: 900 ms traced, 0 view bodies, 0 FFI calls." + ), + "unexpected report: {report}" + ); + assert!( + report.contains("Ask `view: \"spans\"` for what ran instead."), + "{report}" + ); +} + +#[test] +fn a_slot_the_app_never_ran_in_says_what_is_missing() { + let workspace = camino_tempfile::tempdir().unwrap(); + let report = reported(workspace.path(), &Request::default()); + + assert!( + report.starts_with("No traced intervals in tmp/debug-app/test/state."), + "unexpected report: {report}" + ); +} + +/// Silently dropping an argument leaves a caller believing they scoped +/// something. +#[test] +fn an_argument_that_does_not_apply_to_a_view_is_refused() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + driven(root); + + assert_eq!( + reported(root, &Request { + view: Some("hotspots".to_owned()), + step: Some(1), + ..Request::default() + }), + "`step` scopes the app's own intervals, which `view: \"hotspots\"` does not read. A \ + bundle holds samples with no notion of which step caused them. Use `view: \"timeline\"` \ + or `view: \"views\"` for per-step counts." + ); + + assert_eq!( + reported(root, &Request { + view: Some("callgraph".to_owned()), + against: Some("profile-1".to_owned()), + ..Request::default() + }), + "`against` compares counts, and `view: \"callgraph\"` reports sample counts — which are \ + time, and so noisy between runs. Comparing two of them chases ghosts. Compare `view: \ + \"timeline\"`, `view: \"spans\"` or `view: \"views\"` instead, which count work the app \ + did rather than moments a sampler caught it." + ); + + assert_eq!( + reported(root, &Request { + view: Some("spans".to_owned()), + function: Some("deserialize".to_owned()), + ..Request::default() + }), + "`function` names a symbol in the app's binary, which `view: \"spans\"` does not read. \ + Use `span` to narrow to an interval the app timed, or `view: \"hotspots\"` to narrow to \ + a symbol." + ); + + assert_eq!( + reported(root, &Request { + view: Some("views".to_owned()), + top: Some(5), + ..Request::default() + }), + "`top` bounds a bundle-backed table, and `view: \"views\"` shows every interval it found. \ + Narrow it with `span`, `step`, or a time window instead." + ); +} diff --git a/.config/jp/tools/src/debug_app/screenshot.rs b/.config/jp/tools/src/debug_app/screenshot.rs new file mode 100644 index 000000000..0d29d66b6 --- /dev/null +++ b/.config/jp/tools/src/debug_app/screenshot.rs @@ -0,0 +1,289 @@ +//! `debug_app_screenshot` — a picture of the app's window, for the questions +//! the accessibility tree cannot answer. +//! +//! Markdown rendering, scroll bar proportions, truncation, colour, overlapping +//! views: none of that reaches the tree, and all of it is plain in a PNG. +//! Everything else the app does is cheaper to read as text, so this is the +//! escalation path rather than the first look. +//! +//! What comes back is a path, not an image. +//! A tool result is a string all the way to the provider, so the file reaches +//! the assistant only when a human attaches it on a following turn: +//! +//! ```sh +//! jp query -a tmp/debug-app//shot-.png "why is the header clipped?" +//! ``` +//! +//! Capturing needs the Screen Recording grant, which is a different grant from +//! the Accessibility one the rest of these tools need. +//! Missing it, the window server answers with the desktop instead of the +//! window, so the grant is checked before anything is written: a picture of the +//! wrong thing is worse than an error. + +use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, +}; + +use camino::{Utf8Path, Utf8PathBuf}; +use jp_tool::Outcome; +use serde::Deserialize; + +use crate::{ + Context, Error, Tool, + debug_app::{ + driver, + session::{Session, Slot}, + }, + util::{ + ToolResult, error, + runner::{DuctProcessRunner, ProcessRunner}, + }, +}; + +/// What the Screen Recording grant being absent means for a capture. +/// +/// Shared with `debug_app_pixels`, which captures the same way and fails the +/// same way when the grant is missing. +pub(crate) const NO_SCREEN_RECORDING: &str = + "The Screen Recording grant is missing, so a capture would photograph the desktop rather than \ + the app's window. Grant it to the terminal application running these tools, under System \ + Settings > Privacy & Security > Screen & System Audio Recording, then start a new terminal \ + session.\n\nThis is a separate grant from the Accessibility one the other `debug_app_*` \ + tools need: holding one says nothing about the other."; + +/// What `jpdrive windowid` reports. +/// +/// Shared with `debug_app_pixels`, which captures the same way and needs the +/// same distinctions when there is nothing to capture. +#[derive(Debug, Deserialize)] +pub(crate) struct WindowList { + /// Whether the driver may read other applications' screen content. + pub screen_recording: bool, + + /// The app's capturable windows, front to back. + pub windows: Vec, + + /// Windows the app has on another Space. + /// + /// Absent from every on-screen enumeration and from the accessibility tree, + /// so an app with only these looks exactly like an app with no window at + /// all. + #[serde(default)] + pub other_spaces: Vec, +} + +/// One window, as the window server numbers it. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct Window { + pub id: u32, + pub title: Option, + pub width: u32, + pub height: u32, +} + +/// Tool entrypoint. +#[allow(clippy::unused_async, reason = "awaited by the debug_app dispatcher")] +pub(crate) async fn debug_app_screenshot(ctx: &Context, _t: &Tool) -> ToolResult { + if ctx.action.is_format_arguments() { + return Ok(format_preview().into()); + } + + if !cfg!(target_os = "macos") { + return error( + "debug_app_screenshot only supports macOS: it captures a window through the macOS \ + window server.", + ); + } + + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_millis()); + + let dir = Session::dir(&ctx.root, &Slot::for_context(ctx)); + run(&ctx.root, &dir, millis, &DuctProcessRunner) +} + +fn format_preview() -> String { + "`debug_app_screenshot`\n\nWill execute:\n\n```sh\njust build-drive\njpdrive windowid --pid \ + \nscreencapture -l -o -x tmp/debug-app//shot-.png\n```\n\nWrites a \ + PNG of the frontmost window of the app recorded in\n`tmp/debug-app//session.json` and \ + returns its path. The image itself does not\nreach the assistant: attach the file on a \ + following turn to have it looked at.\n\nNeeds the Screen Recording grant, which is a \ + different grant from the Accessibility\none the other `debug_app_*` tools need.\n\nReads \ + only. Nothing about the app's state is changed.\n" + .to_owned() +} + +/// Where a capture taken at `millis` is written. +/// +/// Timestamped rather than fixed, so a sequence of shots can be compared +/// against each other instead of each one erasing the last. +fn shot_path(dir: &Utf8Path, millis: u128) -> Utf8PathBuf { + dir.join(format!("shot-{millis}.png")) +} + +/// Capture the app's frontmost window and report where it landed. +fn run(root: &Utf8Path, dir: &Utf8Path, millis: u128, runner: &dyn ProcessRunner) -> ToolResult { + let session = Session::resolve(dir)?; + let bin = driver::locate(root, runner)?; + let list = windows(&bin, session.pid, root, runner)?; + + if !list.screen_recording { + return error(NO_SCREEN_RECORDING); + } + + let Some(window) = list.windows.first() else { + return error(no_window(session.pid, &list, "capture")); + }; + + let path = shot_path(dir, millis); + let size = capture(window.id, &path, root, runner)?; + + Ok(Outcome::Success { + content: report(root, &session, window, list.windows.len(), &path, size), + }) +} + +/// Why there is nothing to act on, and which of the two reasons it is. +/// +/// A window on another Space and no window at all are indistinguishable from +/// every on-screen enumeration and from the accessibility tree alike, and the +/// difference is the whole of what to do next: switch desktop, or start the +/// app. +/// Told apart by asking the window server for windows on *every* Space and +/// subtracting the ones on this one. +pub(crate) fn no_window(pid: u32, list: &WindowList, verb: &str) -> String { + if list.other_spaces.is_empty() { + return format!( + "The app (pid {pid}) has no window at all, so there is nothing to {verb}. A window \ + that is minimized or closed is absent from the window server's list entirely." + ); + } + + format!( + "The app (pid {pid}) has {} window(s), all of them on another Space, so there is nothing \ + to {verb} and the accessibility tree reports none either. Switch to the desktop the app \ + is on, or move its window to this one.", + list.other_spaces.len() + ) +} + +/// Ask the driver which windows the app has, and whether they can be captured. +pub(crate) fn windows( + bin: &Utf8Path, + pid: u32, + root: &Utf8Path, + runner: &dyn ProcessRunner, +) -> Result { + let output = runner + .run(bin.as_str(), &["windowid", "--pid", &pid.to_string()], root) + .map_err(|e| format!("Failed to spawn {bin}: {e}"))?; + + if !output.success() { + return Err(driver::describe_failure( + "windowid", + bin, + pid, + root, + runner, + &output.stdout, + &output.stderr, + ) + .into()); + } + + parse(&output.stdout) +} + +/// Read the driver's window list. +fn parse(stdout: &str) -> Result { + serde_json::from_str(stdout) + .map_err(|e| format!("Failed to parse the window list `jpdrive` reported: {e}").into()) +} + +/// Write a PNG of window `id`, and answer how many bytes it holds. +/// +/// `-o` leaves out the drop shadow, which is otherwise a wide transparent +/// margin around every window. +/// +/// A zero-byte file is treated as a failure: `screencapture` reports success +/// for a window it could not read, and an empty PNG returned as a result reads +/// like a capture that worked. +fn capture( + id: u32, + path: &Utf8Path, + root: &Utf8Path, + runner: &dyn ProcessRunner, +) -> Result { + let output = runner + .run( + "screencapture", + &["-l", &id.to_string(), "-o", "-x", path.as_str()], + root, + ) + .map_err(|e| format!("Failed to spawn `screencapture`: {e}"))?; + + if !output.success() { + return Err(format!( + "`screencapture` refused to capture window {id}: {}", + output.stderr.trim_end() + ) + .into()); + } + + let size = fs::metadata(path).map(|m| m.len()).unwrap_or_default(); + if size == 0 { + return Err(format!( + "`screencapture` reported success but left nothing at {path}. The window may have \ + closed while it was being read." + ) + .into()); + } + + Ok(size) +} + +/// Render the capture report. +fn report( + root: &Utf8Path, + session: &Session, + window: &Window, + count: usize, + path: &Utf8Path, + size: u64, +) -> String { + let shown = path.strip_prefix(root).unwrap_or(path); + let title = window + .title + .as_deref() + .map_or(String::new(), |title| format!(" {title:?}")); + + let mut report = format!( + "Captured window {}{title} of the app (pid {}), {}x{} points.\n\nWritten to `{shown}` ({} \ + KiB).\n", + window.id, + session.pid, + window.width, + window.height, + size.div_ceil(1024), + ); + + if count > 1 { + report.push_str(&format!( + "\nThe app has {count} windows on screen. This is the frontmost one.\n" + )); + } + + report.push_str(&format!( + "\nA tool result is text, so the image does not reach the assistant from here. Attach the \ + file on the next turn to have it looked at:\n\n```sh\njp query -a {shown} \"what is \ + wrong with this layout?\"\n```\n" + )); + + report +} + +#[cfg(test)] +#[path = "screenshot_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/screenshot_tests.rs b/.config/jp/tools/src/debug_app/screenshot_tests.rs new file mode 100644 index 000000000..8572f4f9b --- /dev/null +++ b/.config/jp/tools/src/debug_app/screenshot_tests.rs @@ -0,0 +1,291 @@ +use std::fs; + +use camino::{Utf8Path, Utf8PathBuf}; + +use super::{NO_SCREEN_RECORDING, Window, WindowList, no_window, parse, report, run, shot_path}; +use crate::{ + debug_app::session::{Console, Session, Slot}, + util::runner::MockProcessRunner, +}; + +/// A slot every test in this file shares, so paths are predictable. +fn dir_for(root: &Utf8Path) -> Utf8PathBuf { + Session::dir(root, &Slot::fixed("test")) +} + +fn session() -> Session { + let dir = dir_for(Utf8Path::new("/repo")); + Session { + pid: 4321, + bundle: "/derived/JP.app".into(), + configuration: "Debug".to_owned(), + workspace: "/repo/tmp/debug-app/test/workspace".into(), + state_dir: dir.join("state"), + user_data_dir: dir.join("data"), + stdout: Console::new(dir.join("console.out")), + stderr: Console::new(dir.join("console.err")), + trace: Console::new(dir.join("state/trace.jsonl")), + reported_footprint_mb: None, + dsym: None, + allocation_stacks: false, + } +} + +fn window(title: Option<&str>) -> Window { + Window { + id: 7412, + title: title.map(ToOwned::to_owned), + width: 1200, + height: 800, + } +} + +/// Record a live session, and stage a file where `driver::locate` looks for the +/// driver binary. +/// +/// The recorded pid is this process: `Session::resolve` refuses a pid that is +/// not running, so a fabricated one would fail before reaching the branch under +/// test. +fn record(root: &Utf8Path) -> Utf8PathBuf { + let dir = dir_for(root); + let session = Session { + pid: std::process::id(), + bundle: "/derived/JP.app".into(), + configuration: "Debug".to_owned(), + workspace: root.join("workspace"), + state_dir: dir.join("state"), + user_data_dir: dir.join("data"), + stdout: Console::new(dir.join("console.out")), + stderr: Console::new(dir.join("console.err")), + trace: Console::new(dir.join("state/trace.jsonl")), + reported_footprint_mb: None, + dsym: None, + allocation_stacks: false, + }; + + session.store(&dir).unwrap(); + fs::create_dir_all(&session.state_dir).unwrap(); + fs::write(session.pid_path(), format!("{}\n", session.pid)).unwrap(); + + let bin_dir = root.join("driver-bin"); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(bin_dir.join("jpdrive"), "").unwrap(); + bin_dir +} + +/// A runner that answers the two build lookups [`driver::locate`] makes, and +/// then the window list. +fn runner(bin_dir: &Utf8Path, windows: &str) -> MockProcessRunner { + MockProcessRunner::builder() + .expect("just") + .args(&["build-drive"]) + .returns_success("") + .expect("swift") + .returns_success(format!("{bin_dir}\n")) + .expect(bin_dir.join("jpdrive").as_str()) + .returns_success(windows) +} + +fn content(outcome: jp_tool::Outcome) -> String { + match outcome { + jp_tool::Outcome::Success { content } => content, + jp_tool::Outcome::Error { message, .. } => message, + other @ jp_tool::Outcome::NeedsInput { .. } => panic!("unexpected outcome: {other:?}"), + } +} + +/// Without the grant the window server hands back the desktop, which looks like +/// a successful capture of the wrong thing. +/// Nothing may be written on that path, and `screencapture` must never run. +#[test] +fn refuses_without_the_screen_recording_grant() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let bin_dir = record(root); + let listed = r#"{"screen_recording":false,"windows":[{"id":7412,"title":"JP","width":1200,"height":800}]}"#; + + let outcome = run( + root, + &dir_for(root), + 1_730_000_000_123, + &runner(&bin_dir, listed), + ) + .unwrap(); + + assert_eq!(content(outcome), NO_SCREEN_RECORDING); + assert_eq!( + fs::read_dir(dir_for(root)) + .unwrap() + .filter_map(|entry| Some(entry.ok()?.file_name().to_str()?.to_owned())) + .filter(|name| name.starts_with("shot-")) + .count(), + 0, + "a refused capture must leave no file behind" + ); +} + +#[test] +fn reports_an_app_with_no_window_on_screen() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let bin_dir = record(root); + let listed = r#"{"screen_recording":true,"windows":[]}"#; + + let outcome = run( + root, + &dir_for(root), + 1_730_000_000_123, + &runner(&bin_dir, listed), + ) + .unwrap(); + + assert_eq!( + content(outcome), + format!( + "The app (pid {}) has no window at all, so there is nothing to capture. A window that \ + is minimized or closed is absent from the window server's list entirely.", + std::process::id() + ) + ); +} + +/// A window on another desktop is missing from the on-screen list and from the +/// accessibility tree alike, which reads exactly like an app that never opened +/// one. +/// Telling the two apart is the difference between switching desktop and +/// hunting a bug that is not there — which is what happened before this +/// existed. +#[test] +fn distinguishes_a_window_on_another_space_from_no_window() { + let elsewhere = WindowList { + screen_recording: true, + windows: vec![], + other_spaces: vec![window(Some("JP"))], + }; + + assert_eq!( + no_window(4321, &elsewhere, "capture"), + "The app (pid 4321) has 1 window(s), all of them on another Space, so there is nothing to \ + capture and the accessibility tree reports none either. Switch to the desktop the app is \ + on, or move its window to this one." + ); +} + +/// The verb is the caller's, so one message serves the capture and the scan. +#[test] +fn names_what_the_caller_was_trying_to_do() { + let empty = WindowList { + screen_recording: true, + windows: vec![], + other_spaces: vec![], + }; + + assert!(no_window(1, &empty, "read").contains("nothing to read")); +} + +/// The exact document `jpdrive windowid` writes. +/// Nothing else checks that the two sides agree on the key names, and a +/// mismatch reads as an app with no windows rather than as a parse failure. +#[test] +fn reads_the_document_the_driver_writes() { + let listed = r#"{ + "screen_recording" : true, + "windows" : [ + { + "height" : 800, + "id" : 7412, + "title" : "JP", + "width" : 1200 + } + ] + }"#; + + let list = parse(listed).unwrap(); + + assert!(list.screen_recording); + assert_eq!(list.windows.len(), 1); + assert_eq!(list.windows[0].id, 7412); + assert_eq!(list.windows[0].title.as_deref(), Some("JP")); + assert_eq!(list.windows[0].width, 1200); + assert_eq!(list.windows[0].height, 800); +} + +#[test] +fn rejects_a_window_list_it_cannot_read() { + let error = parse("not json").unwrap_err().to_string(); + + assert!( + error.starts_with("Failed to parse the window list `jpdrive` reported:"), + "unexpected error: {error}" + ); +} + +#[test] +fn reports_where_the_capture_landed_and_how_to_attach_it() { + let report = report( + Utf8Path::new("/repo"), + &session(), + &window(Some("JP - jp")), + 1, + Utf8Path::new("/repo/tmp/debug-app/test/shot-1730000000123.png"), + 188_416, + ); + + assert_eq!( + report, + "Captured window 7412 \"JP - jp\" of the app (pid 4321), 1200x800 points.\n\nWritten to \ + `tmp/debug-app/test/shot-1730000000123.png` (184 KiB).\n\nA tool result is text, so the \ + image does not reach the assistant from here. Attach the file on the next turn to have \ + it looked at:\n\n```sh\njp query -a tmp/debug-app/test/shot-1730000000123.png \"what is \ + wrong with this layout?\"\n```\n" + ); +} + +/// A second window is the case where the capture answers a question about the +/// wrong one, so the report says which it took. +#[test] +fn says_when_the_app_has_more_than_one_window() { + let report = report( + Utf8Path::new("/repo"), + &session(), + &window(Some("JP - jp")), + 3, + Utf8Path::new("/repo/tmp/debug-app/test/shot-1730000000123.png"), + 188_416, + ); + + assert!( + report.contains("\nThe app has 3 windows on screen. This is the frontmost one.\n"), + "unexpected report: {report}" + ); +} + +#[test] +fn names_an_untitled_window_by_its_number_alone() { + let report = report( + Utf8Path::new("/repo"), + &session(), + &window(None), + 1, + Utf8Path::new("/repo/tmp/debug-app/test/shot-1730000000123.png"), + 188_416, + ); + + assert!( + report.starts_with("Captured window 7412 of the app (pid 4321), 1200x800 points.\n"), + "unexpected report: {report}" + ); +} + +/// Two shots taken in one session are compared against each other, so the +/// second must not erase the first. +#[test] +fn names_each_capture_by_when_it_was_taken() { + let dir = Utf8Path::new("/repo/tmp/debug-app/test"); + + assert_eq!( + shot_path(dir, 1_730_000_000_123), + "/repo/tmp/debug-app/test/shot-1730000000123.png" + ); + assert_ne!(shot_path(dir, 1), shot_path(dir, 2)); +} diff --git a/.config/jp/tools/src/debug_app/session.rs b/.config/jp/tools/src/debug_app/session.rs new file mode 100644 index 000000000..6c19e010d --- /dev/null +++ b/.config/jp/tools/src/debug_app/session.rs @@ -0,0 +1,442 @@ +//! The running app the `debug_app_*` tools address. +//! +//! The app is a long-lived GUI process, so the running instance *is* the +//! session. +//! [`Session`] records which process `debug_app_launch` started and where that +//! process keeps its state; every other tool loads the record and calls +//! [`Session::resolve`] before touching anything. +//! +//! Resolution is what makes the implicit global state safe to address. +//! A tool that silently acted on whichever instance happened to be running +//! would report on an app the caller never started, so every mismatch is an +//! error naming what was expected, what was found, and what to do next. + +use std::{ + fs, io, + io::{Read as _, Seek as _, SeekFrom}, +}; + +use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; + +use crate::{Context, Error}; + +/// How much of a console file to quote when reporting an app that is gone. +const TAIL_BYTES: u64 = 4096; + +/// The app's own trace stream, inside the state directory. +const TRACE_FILE: &str = "trace.jsonl"; + +/// Where the app reports the ASLR slide of its own main image. +const SLIDE_FILE: &str = "slide"; + +/// Where the app writes its trace, given the directory it keeps state in. +pub(crate) fn trace_path(state_dir: &Utf8Path) -> Utf8PathBuf { + state_dir.join(TRACE_FILE) +} + +/// Where a slot's app keeps its state, whether or not a session is recorded. +/// +/// Fixed by the slot rather than read from the session record, because reading +/// what a run left behind has to work after `debug_app_quit` has removed that +/// record. +pub(crate) fn state_dir(dir: &Utf8Path) -> Utf8PathBuf { + dir.join("state") +} + +/// A captured output stream and how much of it has been reported. +/// +/// The default names no file and reads as empty forever. +/// It is what a session record written before a stream existed deserializes to, +/// and reading nothing is the right answer there: an app old enough to predate +/// the record also predates anything writing that stream. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub(crate) struct Console { + pub path: Utf8PathBuf, + + /// Byte offset up to which the stream has already been returned to a + /// caller. + pub offset: u64, +} + +impl Console { + pub(crate) fn new(path: Utf8PathBuf) -> Self { + Self { path, offset: 0 } + } + + /// Everything written since the last read, advancing the offset past it. + /// + /// A file shorter than the offset means it was truncated under us — a + /// relaunch that reused the path, most likely — so the whole file is + /// returned rather than nothing. + pub(crate) fn delta(&mut self) -> Result { + let mut file = match fs::File::open(&self.path) { + Ok(file) => file, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(String::new()), + Err(e) => return Err(format!("Failed to open {}: {e}", self.path).into()), + }; + + let size = file.metadata()?.len(); + if size < self.offset { + self.offset = 0; + } + + file.seek(SeekFrom::Start(self.offset))?; + let mut buf = Vec::new(); + file.read_to_end(&mut buf)?; + self.offset = size.max(self.offset + buf.len() as u64); + + Ok(String::from_utf8_lossy(&buf).into_owned()) + } + + /// The last [`TAIL_BYTES`] of the stream, regardless of the offset. + /// + /// For reporting on an app that is already gone, where what matters is what + /// it said last rather than what has been reported before. + pub(crate) fn tail(&self) -> String { + let Ok(mut file) = fs::File::open(&self.path) else { + return String::new(); + }; + let Ok(size) = file.metadata().map(|m| m.len()) else { + return String::new(); + }; + + if file + .seek(SeekFrom::Start(size.saturating_sub(TAIL_BYTES))) + .is_err() + { + return String::new(); + } + + let mut buf = Vec::new(); + if file.read_to_end(&mut buf).is_err() { + return String::new(); + } + + String::from_utf8_lossy(&buf).into_owned() + } +} + +/// The app instance a `debug_app_*` tool acts on. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct Session { + /// The process id the app reported at launch, read from `/pid`. + pub pid: u32, + + /// The bundle that was launched. + pub bundle: Utf8PathBuf, + + /// The Xcode configuration the bundle was built in. + pub configuration: String, + + /// The workspace the app was pointed at. + pub workspace: Utf8PathBuf, + + /// `JP_DEBUG_STATE_DIR`: where the app keeps its recents list and its pid. + pub state_dir: Utf8PathBuf, + + /// `JP_USER_DATA_DIR`: the app's user-local conversation store. + pub user_data_dir: Utf8PathBuf, + + pub stdout: Console, + pub stderr: Console, + + /// The intervals and footprint samples the app writes about its own work. + /// + /// Kept off both console streams on purpose: those are reported as deltas + /// on every snapshot, and a trace stream on either would bury whatever + /// `AppKit` had to say under our own instrumentation. + #[serde(default)] + pub trace: Console, + + /// The memory footprint last reported to a caller, in MiB. + /// + /// Held across calls so a snapshot can say how far the footprint moved + /// since the previous one rather than only within its own delta. + #[serde(default)] + pub reported_footprint_mb: Option, + + /// The dSYM matching the launched binary, when the build produced one. + /// + /// Recorded here because resolving it means asking `xcodebuild` where the + /// build landed, and nothing that reads a profile afterwards has a reason + /// to run a build. + #[serde(default)] + pub dsym: Option, + + /// Whether the app keeps a stack for every allocation it makes. + /// + /// Set by launching under `MallocStackLogging`, which libmalloc reads at + /// process start, so this is the whole answer and it cannot change while + /// the app runs. + /// A profile bracket asked for allocations against a session where this is + /// false has to refuse and name the relaunch, rather than record an + /// instrument that would find nothing. + #[serde(default)] + pub allocation_stacks: bool, +} + +/// Environment variable overriding which slot a run is scoped to. +pub(crate) const SLOT_VAR: &str = "JP_DEBUG_APP_SLOT"; + +/// The slot a run with nothing to derive one from is scoped to. +const FALLBACK_SLOT: &str = "default"; + +/// One agent's private everything: its own session record, state and user-data +/// directories, console files, scratch workspace, and app bundle. +/// +/// Derived from the conversation rather than defaulted, because a default is a +/// collision waiting for a second agent: nothing would make either of them +/// choose otherwise, and both would drive the same instance. +/// A conversation is already one agent driving one app, and an agent cannot +/// forget to be itself. +/// +/// [`SLOT_VAR`] overrides it, for the case where two conversations should share +/// one running instance on purpose. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Slot(String); + +impl Slot { + /// The slot this invocation is scoped to. + pub(crate) fn for_context(ctx: &Context) -> Slot { + let named = std::env::var(SLOT_VAR).ok(); + Slot::named(named.as_deref(), &ctx.conversation_id) + } + + /// The slot an override and a conversation resolve to. + /// + /// Reduced to what a bundle identifier accepts: the slot ends up inside a + /// reverse-DNS identifier, which takes only letters, digits and hyphens. + /// Anything else is dropped rather than rejected, and a name left with + /// nothing usable falls through — a slot that cannot be spelled is a + /// naming problem, not a reason for the tools to stop working. + fn named(overridden: Option<&str>, conversation: &str) -> Slot { + for candidate in [overridden.unwrap_or_default(), conversation] { + let cleaned: String = candidate + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '-') + .collect(); + + if !cleaned.is_empty() { + return Slot(cleaned); + } + } + + Slot(FALLBACK_SLOT.to_owned()) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +impl Slot { + /// A slot with a fixed name, so a test's paths do not depend on the + /// environment or on which conversation ran it. + pub(crate) fn fixed(name: &str) -> Slot { + Slot(name.to_owned()) + } +} + +impl std::fmt::Display for Slot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl Session { + /// The directory every artifact of a driven run lives under. + pub(crate) fn dir(root: &Utf8Path, slot: &Slot) -> Utf8PathBuf { + root.join("tmp/debug-app").join(slot.as_str()) + } + + /// Where the record itself is kept, inside a slot's directory. + pub(crate) fn path(dir: &Utf8Path) -> Utf8PathBuf { + dir.join("session.json") + } + + /// Read the recorded session, or `None` when no run has been started. + pub(crate) fn load(dir: &Utf8Path) -> Result, Error> { + let path = Self::path(dir); + let raw = match fs::read_to_string(&path) { + Ok(raw) => raw, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("Failed to read {path}: {e}").into()), + }; + + serde_json::from_str(&raw) + .map(Some) + .map_err(|e| format!("Failed to parse {path}: {e}. Remove it and launch again.").into()) + } + + /// Write the record, replacing any earlier one. + pub(crate) fn store(&self, dir: &Utf8Path) -> Result<(), Error> { + let path = Self::path(dir); + fs::create_dir_all(dir)?; + let json = serde_json::to_string_pretty(self)?; + fs::write(&path, format!("{json}\n")) + .map_err(|e| format!("Failed to write {path}: {e}").into()) + } + + /// Load the recorded session and verify the app it names is still the app + /// that is running. + /// + /// Returns an error rather than a stale session, because acting on the + /// wrong instance is worse than not acting: a snapshot of an app the caller + /// never launched looks like a real answer. + pub(crate) fn resolve(dir: &Utf8Path) -> Result { + let Some(session) = Self::load(dir)? else { + return Err(format!( + "No app session recorded at {}. Start one with `debug_app_launch`.", + Self::path(dir) + ) + .into()); + }; + + match session.reported_pid() { + None => Err(format!( + "The app's pid file at {} is gone, so the recorded session (pid {}) can no longer \ + be confirmed. The app was most likely quit outside these tools. Run \ + `debug_app_launch` to start a new one.", + session.pid_path(), + session.pid + ) + .into()), + + Some(pid) if pid != session.pid => Err(format!( + "The app running under {} reports pid {pid}, but the recorded session is pid {}. \ + Something launched the app outside these tools. Run `debug_app_quit` and then \ + `debug_app_launch` to get back to a known state.", + session.state_dir, session.pid + ) + .into()), + + Some(pid) if !pid_is_alive(pid) => { + let tail = session.stderr.tail(); + let note = if tail.trim().is_empty() { + "It wrote nothing to stderr before going away.".to_owned() + } else { + format!( + "Its last stderr output was:\n\n```\n{}\n```", + tail.trim_end() + ) + }; + + Err(format!( + "The app recorded as pid {pid} is no longer running — it quit or crashed. \ + {note}\n\nRun `debug_app_launch` to start a new one." + ) + .into()) + } + + Some(_) => Ok(session), + } + } + + /// Whether the recorded app is running right now. + pub(crate) fn is_running(&self) -> bool { + self.reported_pid() + .is_some_and(|pid| pid == self.pid && pid_is_alive(pid)) + } + + /// Where the app writes its own process id. + pub(crate) fn pid_path(&self) -> Utf8PathBuf { + self.state_dir.join("pid") + } + + /// The ASLR slide the app reported for its own main image. + /// + /// Read from the process that has it rather than recovered from a trace, + /// which is both exact and the only option for a recording that attached + /// after the app's images were already mapped. + /// `None` for a build that does not report one. + pub(crate) fn reported_slide(&self) -> Option { + let raw = fs::read_to_string(self.state_dir.join(SLIDE_FILE)).ok()?; + + raw.trim().parse::().ok().map(xct2cli::Slide::new) + } + + /// The pid the app currently claims, or `None` when it has not claimed one. + fn reported_pid(&self) -> Option { + fs::read_to_string(self.pid_path()) + .ok()? + .trim() + .parse::() + .ok() + } +} + +/// A signal one of these tools sends. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Signal { + /// Ask a process to stop the way Ctrl-C would. + Int, + Term, + Kill, +} + +/// Signalling a process, and observing whether it is still there. +/// +/// A seam, so an escalation ladder can be driven by a process that reliably +/// survives `SIGTERM`. +/// Building that fixture out of a real process means relying on shell trap +/// semantics, which turned out to be too subtle to trust: a shell told to +/// ignore `SIGTERM` still exited, and the test passed through the first rung +/// while claiming to cover the second. +pub(crate) trait Signals { + fn send(&self, pid: u32, signal: Signal); + + fn is_alive(&self, pid: u32) -> bool; +} + +/// Production [`Signals`]: real `kill(2)`. +pub(crate) struct RealSignals; + +impl Signals for RealSignals { + #[cfg(unix)] + fn send(&self, pid: u32, signal: Signal) { + let sig = match signal { + Signal::Int => libc::SIGINT, + Signal::Term => libc::SIGTERM, + Signal::Kill => libc::SIGKILL, + }; + + // Best-effort: a failure means the process has already exited, which + // `is_alive` then observes. + unsafe { + libc::kill(pid.cast_signed(), sig); + } + } + + #[cfg(not(unix))] + fn send(&self, _pid: u32, _signal: Signal) {} + + fn is_alive(&self, pid: u32) -> bool { + pid_is_alive(pid) + } +} + +/// Whether a process with `pid` is currently alive. +/// +/// On non-unix targets, conservatively returns `true`: these tools are macOS +/// only, and a wrong `false` would report a running app as gone. +#[cfg(unix)] +pub(crate) fn pid_is_alive(pid: u32) -> bool { + // `kill(pid, 0)` runs the kernel's permission and existence checks without + // sending a signal: 0 => alive; EPERM => alive but not ours; ESRCH => gone. + if unsafe { libc::kill(pid.cast_signed(), 0) } == 0 { + return true; + } + + io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) +} + +#[cfg(not(unix))] +pub(crate) fn pid_is_alive(_pid: u32) -> bool { + true +} + +#[cfg(test)] +#[path = "session_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/session_tests.rs b/.config/jp/tools/src/debug_app/session_tests.rs new file mode 100644 index 000000000..ba484e226 --- /dev/null +++ b/.config/jp/tools/src/debug_app/session_tests.rs @@ -0,0 +1,342 @@ +use std::fs; + +use camino::Utf8Path; + +use super::{Console, Session, Slot, pid_is_alive}; + +/// A slot every test in this file shares, so paths are predictable. +fn slot() -> Slot { + Slot::fixed("test") +} + +/// Above macOS's default maximum pid, so no process can hold it. +const DEAD_PID: u32 = 4_000_000; + +/// A session whose state directory is inside `root`. +fn session(root: &Utf8Path, pid: u32) -> Session { + let dir = Session::dir(root, &slot()); + Session { + pid, + bundle: Utf8Path::new("/tmp/JP.app").to_owned(), + configuration: "Debug".to_owned(), + workspace: root.join("workspace"), + state_dir: dir.join("state"), + user_data_dir: dir.join("data"), + stdout: Console::new(dir.join("console.out")), + stderr: Console::new(dir.join("console.err")), + trace: Console::new(dir.join("state/trace.jsonl")), + reported_footprint_mb: None, + dsym: None, + allocation_stacks: false, + } +} + +/// A record written before the trace stream existed. +/// +/// Kept as literal JSON rather than built from the current struct, which would +/// pass whatever fields that struct happens to have and prove nothing. +const OLD_RECORD: &str = r#"{ + "pid": 4321, + "bundle": "/tmp/JP.app", + "configuration": "Debug", + "workspace": "/repo/workspace", + "state_dir": "/repo/tmp/debug-app/test/state", + "user_data_dir": "/repo/tmp/debug-app/test/data", + "stdout": { "path": "/repo/tmp/debug-app/test/console.out", "offset": 12 }, + "stderr": { "path": "/repo/tmp/debug-app/test/console.err", "offset": 0 } +}"#; + +/// A tool that refused to load a record written by the previous build would +/// strand a running app: nothing can address it, and nothing can stop it. +#[test] +fn a_record_without_a_trace_stream_still_loads() { + let workspace = camino_tempfile::tempdir().unwrap(); + let dir = Session::dir(workspace.path(), &slot()); + fs::create_dir_all(&dir).unwrap(); + fs::write(Session::path(&dir), OLD_RECORD).unwrap(); + + let mut loaded = Session::load(&dir).unwrap().unwrap(); + + assert_eq!(loaded.pid, 4321); + assert_eq!(loaded.reported_footprint_mb, None); + assert_eq!(loaded.trace.path, ""); + // The stream reads as empty rather than as an error, so a snapshot of that + // app reports its tree and console as it always did. + assert_eq!(loaded.trace.delta().unwrap(), ""); +} + +/// Write the pid file the app is responsible for. +fn write_pid(session: &Session, pid: u32) { + fs::create_dir_all(&session.state_dir).unwrap(); + fs::write(session.pid_path(), format!("{pid}\n")).unwrap(); +} + +/// The conversation is what makes two agents land in different slots without +/// either of them choosing to. +#[test] +fn slot_comes_from_the_conversation() { + assert_eq!(Slot::named(None, "jp-c12345").as_str(), "jp-c12345"); + assert_ne!( + Slot::named(None, "jp-c12345"), + Slot::named(None, "jp-c67890") + ); +} + +/// Two conversations sharing one running instance is a deliberate act, so it +/// takes an explicit name. +#[test] +fn an_override_wins_over_the_conversation() { + assert_eq!(Slot::named(Some("shared"), "jp-c12345").as_str(), "shared"); +} + +/// The slot lands inside a reverse-DNS bundle identifier, which accepts only +/// letters, digits and hyphens. +#[test] +fn slot_keeps_only_what_a_bundle_identifier_accepts() { + assert_eq!( + Slot::named(Some("agent_two/../etc"), "").as_str(), + "agenttwoetc" + ); + assert_eq!(Slot::named(None, "jp/c/123").as_str(), "jpc123"); +} + +/// A name left with nothing usable falls through to the conversation, and a +/// conversation with nothing usable falls back rather than failing. +#[test] +fn slot_falls_through_then_back() { + assert_eq!(Slot::named(Some("///"), "jp-c12345").as_str(), "jp-c12345"); + assert_eq!(Slot::named(None, "").as_str(), "default"); + assert_eq!(Slot::named(Some(""), "///").as_str(), "default"); +} + +#[test] +fn load_returns_none_without_a_record() { + let workspace = camino_tempfile::tempdir().unwrap(); + + assert!( + Session::load(&Session::dir(workspace.path(), &slot())) + .unwrap() + .is_none() + ); +} + +#[test] +fn store_then_load_round_trips() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let stored = session(root, 4321); + + stored.store(&Session::dir(root, &slot())).unwrap(); + let loaded = Session::load(&Session::dir(root, &slot())) + .unwrap() + .unwrap(); + + assert_eq!(loaded.pid, 4321); + assert_eq!(loaded.configuration, "Debug"); + assert_eq!(loaded.workspace, stored.workspace); + assert_eq!(loaded.stdout.path, stored.stdout.path); + assert_eq!(loaded.stdout.offset, 0); +} + +#[test] +fn resolve_without_a_record_names_the_launch_tool() { + let workspace = camino_tempfile::tempdir().unwrap(); + + let error = Session::resolve(&Session::dir(workspace.path(), &slot())) + .unwrap_err() + .to_string(); + + assert_eq!( + error, + format!( + "No app session recorded at {}. Start one with `debug_app_launch`.", + Session::path(&Session::dir(workspace.path(), &slot())) + ) + ); +} + +#[test] +fn resolve_without_a_pid_file_reports_an_unconfirmable_session() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let stored = session(root, 4321); + stored.store(&Session::dir(root, &slot())).unwrap(); + + let error = Session::resolve(&Session::dir(root, &slot())) + .unwrap_err() + .to_string(); + + assert_eq!( + error, + format!( + "The app's pid file at {} is gone, so the recorded session (pid 4321) can no longer \ + be confirmed. The app was most likely quit outside these tools. Run \ + `debug_app_launch` to start a new one.", + stored.pid_path() + ) + ); +} + +/// The case the session record exists to catch: an instance nobody here +/// started. +#[test] +fn resolve_with_a_different_pid_reports_the_mismatch() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let stored = session(root, 4321); + stored.store(&Session::dir(root, &slot())).unwrap(); + write_pid(&stored, 9876); + + let error = Session::resolve(&Session::dir(root, &slot())) + .unwrap_err() + .to_string(); + + assert_eq!( + error, + format!( + "The app running under {} reports pid 9876, but the recorded session is pid 4321. \ + Something launched the app outside these tools. Run `debug_app_quit` and then \ + `debug_app_launch` to get back to a known state.", + stored.state_dir + ) + ); +} + +/// Killing the app out from under the tools has to say so, and say what the app +/// complained about on the way out, rather than hang or report an empty +/// snapshot. +#[test] +fn resolve_with_a_dead_process_quotes_the_last_stderr() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let stored = session(root, DEAD_PID); + stored.store(&Session::dir(root, &slot())).unwrap(); + write_pid(&stored, DEAD_PID); + fs::write( + &stored.stderr.path, + "*** Assertion failure in -[NSTableView ...]\n", + ) + .unwrap(); + + let error = Session::resolve(&Session::dir(root, &slot())) + .unwrap_err() + .to_string(); + + assert_eq!( + error, + "The app recorded as pid 4000000 is no longer running — it quit or crashed. Its last \ + stderr output was:\n\n```\n*** Assertion failure in -[NSTableView ...]\n```\n\nRun \ + `debug_app_launch` to start a new one." + ); +} + +#[test] +fn resolve_with_a_dead_and_silent_process_says_so() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let stored = session(root, DEAD_PID); + stored.store(&Session::dir(root, &slot())).unwrap(); + write_pid(&stored, DEAD_PID); + + let error = Session::resolve(&Session::dir(root, &slot())) + .unwrap_err() + .to_string(); + + assert_eq!( + error, + "The app recorded as pid 4000000 is no longer running — it quit or crashed. It wrote \ + nothing to stderr before going away.\n\nRun `debug_app_launch` to start a new one." + ); +} + +#[test] +fn resolve_accepts_a_live_matching_process() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let pid = std::process::id(); + let stored = session(root, pid); + stored.store(&Session::dir(root, &slot())).unwrap(); + write_pid(&stored, pid); + + let resolved = Session::resolve(&Session::dir(root, &slot())).unwrap(); + + assert_eq!(resolved.pid, pid); +} + +#[test] +fn is_running_is_false_for_a_dead_process() { + let workspace = camino_tempfile::tempdir().unwrap(); + let root = workspace.path(); + let stored = session(root, DEAD_PID); + write_pid(&stored, DEAD_PID); + + assert!(!stored.is_running()); +} + +#[test] +fn pid_is_alive_rejects_a_pid_no_process_can_hold() { + assert!(!pid_is_alive(DEAD_PID)); + assert!(pid_is_alive(std::process::id())); +} + +#[test] +fn delta_returns_only_what_was_appended() { + let workspace = camino_tempfile::tempdir().unwrap(); + let path = workspace.path().join("console.err"); + fs::write(&path, "first\n").unwrap(); + + let mut console = Console::new(path.clone()); + assert_eq!(console.delta().unwrap(), "first\n"); + assert_eq!(console.offset, 6); + + fs::write(&path, "first\nsecond\n").unwrap(); + assert_eq!(console.delta().unwrap(), "second\n"); + assert_eq!(console.offset, 13); + + assert_eq!(console.delta().unwrap(), ""); +} + +/// A relaunch truncates the console files, which would otherwise leave the +/// offset past the end of the file and report nothing forever. +#[test] +fn delta_returns_everything_after_a_truncation() { + let workspace = camino_tempfile::tempdir().unwrap(); + let path = workspace.path().join("console.err"); + fs::write(&path, "a long first run\n").unwrap(); + + let mut console = Console::new(path.clone()); + console.delta().unwrap(); + + fs::write(&path, "short\n").unwrap(); + + assert_eq!(console.delta().unwrap(), "short\n"); +} + +#[test] +fn delta_of_a_missing_file_is_empty() { + let workspace = camino_tempfile::tempdir().unwrap(); + let mut console = Console::new(workspace.path().join("nothing-here")); + + assert_eq!(console.delta().unwrap(), ""); + assert_eq!(console.offset, 0); +} + +#[test] +fn tail_ignores_the_offset() { + let workspace = camino_tempfile::tempdir().unwrap(); + let path = workspace.path().join("console.err"); + fs::write(&path, "already reported\n").unwrap(); + + let mut console = Console::new(path); + console.delta().unwrap(); + + assert_eq!(console.tail(), "already reported\n"); +} + +#[test] +fn tail_of_a_missing_file_is_empty() { + let workspace = camino_tempfile::tempdir().unwrap(); + let console = Console::new(workspace.path().join("nothing-here")); + + assert_eq!(console.tail(), ""); +} diff --git a/.config/jp/tools/src/debug_app/snapshot.rs b/.config/jp/tools/src/debug_app/snapshot.rs new file mode 100644 index 000000000..ea77fff54 --- /dev/null +++ b/.config/jp/tools/src/debug_app/snapshot.rs @@ -0,0 +1,206 @@ +//! `debug_app_snapshot` — what the app looks like, and what it has complained +//! about. +//! +//! Two channels, because neither sees the other's failures. +//! The accessibility tree says what the interface structurally is, which is +//! what a caller acts on. +//! The console says what `AppKit` objected to, and a whole class of defect — +//! reentrancy warnings, constraint complaints, exceptions — appears there and +//! nowhere in the tree. +//! +//! Console output is reported as a delta, so a call answers "what happened +//! since I last looked" rather than replaying the run. +//! +//! The app's own trace is a third channel, summarized rather than quoted: it +//! says how long the work behind the tree took and what the process weighs, +//! which neither of the other two can. + +use camino::Utf8Path; +use jp_tool::Outcome; + +use crate::{ + Context, Tool, + debug_app::{ + driver, + session::{Session, Slot}, + trace, tree, + }, + util::{ + ToolResult, error, + paths::{self, Shortening, shorten}, + runner::{DuctProcessRunner, ProcessRunner}, + trace::parse_lines, + }, +}; + +/// Tool entrypoint. +#[allow(clippy::unused_async, reason = "awaited by the debug_app dispatcher")] +pub(crate) async fn debug_app_snapshot(ctx: &Context, t: &Tool) -> ToolResult { + let opts = tree::Options { + identifier: t.opt("identifier")?, + max_matches: t.opt("max_matches")?, + depth: t.opt("depth")?, + max_siblings: t + .opt::("max_siblings")? + .unwrap_or(tree::DEFAULT_MAX_SIBLINGS), + frames: t.opt::("frames")?.unwrap_or(false), + actions: t.opt::("actions")?.unwrap_or(false), + menus: t.opt::("menus")?.unwrap_or(false), + }; + let pasteboard = t.opt::("pasteboard")?.unwrap_or(false); + + if ctx.action.is_format_arguments() { + return Ok(format_preview(&opts, pasteboard).into()); + } + + if !cfg!(target_os = "macos") { + return error( + "debug_app_snapshot only supports macOS: it reads an application's accessibility tree.", + ); + } + + let dir = Session::dir(&ctx.root, &Slot::for_context(ctx)); + run(&ctx.root, &dir, &opts, pasteboard, &DuctProcessRunner) +} + +fn format_preview(opts: &tree::Options, pasteboard: bool) -> String { + // The pid is only known once the session resolves, which happens after the + // preview is rendered. + let args = tree::args(0, opts) + .join(" ") + .replacen("--pid 0", "--pid ", 1); + + let clipboard = if pasteboard { "\npbpaste\n" } else { "" }; + + format!( + "`debug_app_snapshot`\n\nWill execute:\n\n```sh\njust build-drive\njpdrive \ + {args}{clipboard}\n```\n\nReads the accessibility tree of the app recorded in \ + `tmp/debug-app/session.json`,\nand returns it alongside whatever the app has written to \ + its console since the\nlast call.\n\nReads only. Nothing about the app's state is \ + changed.\n" + ) +} + +/// Read the tree and the console, and report both. +fn run( + root: &Utf8Path, + dir: &Utf8Path, + opts: &tree::Options, + pasteboard: bool, + runner: &dyn ProcessRunner, +) -> ToolResult { + let mut session = Session::resolve(dir)?; + let bin = driver::locate(root, runner)?; + + let node = match tree::read(&bin, session.pid, opts, root, runner) { + Ok(Some(node)) => node, + Ok(None) => { + return error(format!( + "No element's identifier begins with `{}`. Drop `identifier` to see what the app \ + reports, or check that the view holding it is on screen: a collapsed sidebar and \ + a background tab are both absent from the tree entirely.", + opts.identifier.as_deref().unwrap_or_default() + )); + } + Err(e) => return error(e.to_string()), + }; + + let clipboard = if pasteboard { + Some(read_pasteboard(root, runner)?) + } else { + None + }; + + let out = session.stdout.delta()?; + let err = session.stderr.delta()?; + + let summary = trace::summarize(&parse_lines(&session.trace.delta()?)); + let traced = trace::render(&summary, session.reported_footprint_mb); + if let Some(footprint) = summary.footprint_mb { + session.reported_footprint_mb = Some(footprint); + } + + session.store(dir)?; + + Ok(Outcome::Success { + content: report( + &session, + &tree::rendered(&node, opts), + clipboard.as_deref(), + &out, + &err, + traced.as_deref(), + &paths::shortenings(root), + ), + }) +} + +/// What the pasteboard holds. +/// +/// Read through `pbpaste` rather than the driver: the pasteboard belongs to the +/// system rather than to the app, so it needs no accessibility grant and no +/// element to hang off. +fn read_pasteboard(root: &Utf8Path, runner: &dyn ProcessRunner) -> Result { + let output = runner + .run("pbpaste", &[], root) + .map_err(|e| format!("Failed to spawn `pbpaste`: {e}"))?; + + if !output.success() { + return Err(format!("`pbpaste` failed: {}", output.stderr.trim_end()).into()); + } + + Ok(output.stdout) +} + +/// Render the snapshot report. +fn report( + session: &Session, + tree: &str, + pasteboard: Option<&str>, + out: &str, + err: &str, + traced: Option<&str>, + shortenings: &[Shortening], +) -> String { + let mut report = format!( + "Snapshot of the app (pid {}) on `{}`.\n\nAccessibility tree:\n\n```\n{tree}```\n", + session.pid, + shorten(session.workspace.as_str(), shortenings) + ); + + if let Some(contents) = pasteboard { + if contents.is_empty() { + report.push_str("\nThe pasteboard is empty.\n"); + } else { + report.push_str(&format!( + "\nPasteboard:\n\n```\n{}\n```\n", + contents.trim_end() + )); + } + } + + for (name, content) in [("stdout", out), ("stderr", err)] { + if content.trim().is_empty() { + continue; + } + + report.push_str(&format!( + "\nConsole ({name}), since the last call:\n\n```\n{}\n```\n", + content.trim_end() + )); + } + + if out.trim().is_empty() && err.trim().is_empty() { + report.push_str("\nNothing new on either console stream since the last call.\n"); + } + + if let Some(traced) = traced { + report.push_str(&format!("\n{traced}\n")); + } + + report +} + +#[cfg(test)] +#[path = "snapshot_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/snapshot_tests.rs b/.config/jp/tools/src/debug_app/snapshot_tests.rs new file mode 100644 index 000000000..fd5594777 --- /dev/null +++ b/.config/jp/tools/src/debug_app/snapshot_tests.rs @@ -0,0 +1,180 @@ +use camino::Utf8Path; + +use super::{format_preview, report}; +use crate::{ + debug_app::{ + session::{Console, Session}, + tree::Options, + }, + util::paths::{Shortening, shortenings_from}, +}; + +/// A repository at `/repo`, so the fixture's paths shorten to relative ones. +fn shortenings() -> Vec { + shortenings_from(Utf8Path::new("/repo"), Some("/Users/jean"), None, None) +} + +fn session() -> Session { + Session { + pid: 4321, + bundle: Utf8Path::new("/tmp/JP.app").to_owned(), + configuration: "Debug".to_owned(), + workspace: Utf8Path::new("/repo/tmp/debug-app/workspace").to_owned(), + state_dir: Utf8Path::new("/repo/tmp/debug-app/state").to_owned(), + user_data_dir: Utf8Path::new("/repo/tmp/debug-app/data").to_owned(), + stdout: Console::new(Utf8Path::new("/repo/tmp/debug-app/console.out").to_owned()), + stderr: Console::new(Utf8Path::new("/repo/tmp/debug-app/console.err").to_owned()), + trace: Console::new(Utf8Path::new("/repo/tmp/debug-app/state/trace.jsonl").to_owned()), + reported_footprint_mb: None, + dsym: None, + allocation_stacks: false, + } +} + +#[test] +fn reports_the_tree_and_both_console_streams() { + let report = report( + &session(), + "AXApplication\n AXWindow \"mac-app\"\n", + None, + "opened workspace\n", + "*** constraint complaint\n", + None, + &shortenings(), + ); + + assert_eq!( + report, + "Snapshot of the app (pid 4321) on `tmp/debug-app/workspace`.\n\nAccessibility \ + tree:\n\n```\nAXApplication\n AXWindow \"mac-app\"\n```\n\nConsole (stdout), since the \ + last call:\n\n```\nopened workspace\n```\n\nConsole (stderr), since the last \ + call:\n\n```\n*** constraint complaint\n```\n" + ); +} + +/// An empty console section would read as "the app said nothing at all", when +/// what it means is "nothing since the last call". +#[test] +fn says_when_neither_stream_has_anything_new() { + let report = report( + &session(), + "AXApplication\n", + None, + "", + " \n", + None, + &shortenings(), + ); + + assert_eq!( + report, + "Snapshot of the app (pid 4321) on `tmp/debug-app/workspace`.\n\nAccessibility \ + tree:\n\n```\nAXApplication\n```\n\nNothing new on either console stream since the last \ + call.\n" + ); +} + +/// The trace block sits last, after whatever the app said on its console: it +/// summarizes the same work those lines came from. +#[test] +fn reports_the_trace_summary_after_the_console() { + let report = report( + &session(), + "AXApplication\n", + None, + "", + "", + Some( + "Trace, since the last call: 1 span. Slowest `transcript.render` 84 ms.\nFootprint \ + 412 MB (+38 MB).", + ), + &shortenings(), + ); + + assert_eq!( + report, + "Snapshot of the app (pid 4321) on `tmp/debug-app/workspace`.\n\nAccessibility \ + tree:\n\n```\nAXApplication\n```\n\nNothing new on either console stream since the last \ + call.\n\nTrace, since the last call: 1 span. Slowest `transcript.render` 84 \ + ms.\nFootprint 412 MB (+38 MB).\n" + ); +} + +/// The preview is what a caller reads before approving the call, so it has to +/// name the command that will run against the app. +#[test] +fn the_preview_names_the_read_it_will_perform() { + let opts = Options { + identifier: Some("sidebar.".to_owned()), + max_matches: Some(1), + ..Options::default() + }; + + assert!(format_preview(&opts, false).contains( + "jpdrive tree --pid --max-siblings 0 --identifier sidebar. --max-matches 1" + )); +} + +/// The pasteboard belongs to the system rather than to the app, so a report +/// that quoted it unasked would leak whatever the user last copied. +#[test] +fn quotes_the_pasteboard_only_when_it_was_asked_for() { + let quoted = report( + &session(), + "AXApplication\n", + Some("jp://jp-c12345\njp://jp-c67890\n"), + "", + "", + None, + &shortenings(), + ); + assert!( + quoted.contains("\nPasteboard:\n\n```\njp://jp-c12345\njp://jp-c67890\n```\n"), + "unexpected report: {quoted}" + ); + + let unasked = report( + &session(), + "AXApplication\n", + None, + "", + "", + None, + &shortenings(), + ); + assert!( + !unasked.contains("Pasteboard"), + "unexpected report: {unasked}" + ); +} + +/// An empty clipboard is an answer — a Copy Link that did nothing looks +/// exactly like this — so it is reported rather than left out. +#[test] +fn says_when_the_pasteboard_is_empty() { + let report = report( + &session(), + "AXApplication\n", + Some(""), + "", + "", + None, + &shortenings(), + ); + + assert!( + report.contains("\nThe pasteboard is empty.\n"), + "unexpected report: {report}" + ); +} + +#[test] +fn the_preview_names_the_pasteboard_read_when_asked() { + let preview = format_preview(&Options::default(), true); + + assert!( + preview.contains("\npbpaste\n"), + "unexpected preview: {preview}" + ); + assert!(!format_preview(&Options::default(), false).contains("pbpaste")); +} diff --git a/.config/jp/tools/src/debug_app/steps.rs b/.config/jp/tools/src/debug_app/steps.rs new file mode 100644 index 000000000..503b523ce --- /dev/null +++ b/.config/jp/tools/src/debug_app/steps.rs @@ -0,0 +1,195 @@ +//! The action vocabulary, as data. +//! +//! A step is a single-key JSON object naming what to do: +//! +//! ```json +//! {"select": {"identifier": "sidebar.row.jp-c12345"}} +//! ``` +//! +//! Steps and the harness that runs them are independent axes. +//! A list is read here and walked by [`drive`] live; a harness that runs the +//! same list under a profiler needs no second copy of the vocabulary. +//! A list is also replayable, diffable, and transcribable into an `XCUITest` +//! case once the flow it describes is understood. +//! +//! Payload validation belongs to `jpdrive`, which owns the schema and reports +//! against it. +//! What is checked here is the shape a list has to have — an array of +//! single-key objects naming a verb something can run — and it is checked for +//! the whole list before the first step runs, because a list abandoned halfway +//! leaves the app in a state nobody asked for. +//! +//! [`drive`]: super::drive + +use serde_json::Value; + +use crate::Error; + +/// Verbs `jpdrive act` runs. +/// +/// Alphabetical, because the list is quoted back in errors. +const DRIVER_VERBS: [&str; 9] = [ + "click", "drag", "menu", "perform", "press", "resize", "select", "type", "wait_for", +]; + +/// The verb the harness answers itself, by reading rather than acting. +pub(crate) const SNAPSHOT: &str = "snapshot"; + +/// Verbs that synthesize input, and so borrow what the person at the keyboard +/// is using. +/// +/// Mouse events go to whatever is on top at a coordinate, and the ordering +/// between applications follows activation — so any of these has to bring the +/// app forward, and moves the pointer to do it. +/// The others reach their target through the accessibility tree and disturb +/// nothing. +const POINTER_VERBS: [&str; 3] = ["click", "drag", "menu"]; + +/// Verbs a caller reaches for that the vocabulary deliberately lacks. +/// +/// Waiting a fixed duration and assuming the work finished is a guess. +/// If a wait cannot be written as a predicate on the tree, the app is missing +/// an identifier, and adding one is the fix. +const SLEEP_VERBS: [&str; 4] = ["sleep", "delay", "pause", "wait"]; + +/// One thing to do, and what to do it to. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Step { + verb: String, + payload: Value, +} + +impl Step { + /// Whether the harness answers this step itself, rather than the driver. + pub(crate) fn is_snapshot(&self) -> bool { + self.verb == SNAPSHOT + } + + /// Whether running this step takes focus and moves the pointer. + /// + /// Read from the verb rather than reported by the driver, because the + /// driver runs one step per process and the decision is about the whole + /// run. + pub(crate) fn perturbs_ambient_state(&self) -> bool { + POINTER_VERBS.contains(&self.verb.as_str()) + } + + /// The step as `jpdrive act --json` reads it. + pub(crate) fn json(&self) -> String { + Value::Object( + [(self.verb.clone(), self.payload.clone())] + .into_iter() + .collect(), + ) + .to_string() + } + + /// One line naming the step, for a report. + /// + /// The verb reads first, so a numbered list of steps scans as a list of + /// verbs rather than a wall of JSON. + pub(crate) fn label(&self) -> String { + match &self.payload { + Value::Null => self.verb.clone(), + Value::Object(map) if map.is_empty() => self.verb.clone(), + payload => format!("{} {payload}", self.verb), + } + } +} + +/// Read a step list. +/// +/// Errors name the step by its position in the list, counting from one, so a +/// caller can find it in what they wrote. +pub(crate) fn parse(value: &Value) -> Result, Error> { + // An array argument sometimes arrives as a JSON string holding the array. + if let Value::String(raw) = value + && let Ok(inner) = serde_json::from_str::(raw) + { + return parse(&inner); + } + + let Some(items) = value.as_array() else { + return Err(format!( + "`steps` is a JSON array of steps, but a {} was given. {VOCABULARY}", + kind(value) + ) + .into()); + }; + + if items.is_empty() { + return Err(format!("`steps` is empty, so there is nothing to do. {VOCABULARY}").into()); + } + + items + .iter() + .enumerate() + .map(|(index, item)| step(index + 1, item)) + .collect() +} + +/// The vocabulary, quoted in every error so a caller can correct in place. +const VOCABULARY: &str = "A step is a single-key object naming one of: click, drag, menu, \ + perform, press, resize, select, snapshot, type, wait_for. For example \ + `{\"select\": {\"identifier\": \"sidebar.row.jp-c12345\"}}`."; + +/// Read one step, `position` being its place in the list counting from one. +fn step(position: usize, value: &Value) -> Result { + let Some(map) = value.as_object() else { + return Err(format!( + "Step {position} is a {}, not an object. {VOCABULARY}", + kind(value) + ) + .into()); + }; + + let mut entries = map.iter(); + let (Some((verb, payload)), None) = (entries.next(), entries.next()) else { + return Err(format!( + "Step {position} names {} verbs ({}). Each step does one thing, so split it into that \ + many steps. {VOCABULARY}", + map.len(), + map.keys().cloned().collect::>().join(", ") + ) + .into()); + }; + + if verb != SNAPSHOT && !DRIVER_VERBS.contains(&verb.as_str()) { + return Err(unknown_verb(position, verb).into()); + } + + Ok(Step { + verb: verb.clone(), + payload: payload.clone(), + }) +} + +/// Why a verb is not one of the ones that exist. +fn unknown_verb(position: usize, verb: &str) -> String { + if SLEEP_VERBS.contains(&verb) { + return format!( + "Step {position} names `{verb}`, and there is no step that waits a fixed duration: \ + waiting and assuming the work finished is a guess. Use `wait_for` against an \ + identifier the app publishes once the work is done. If there is no such identifier, \ + the app is missing one, and adding it is the fix." + ); + } + + format!("Step {position} names an unknown verb `{verb}`. {VOCABULARY}") +} + +/// What a value is, for an error that says what was given instead. +fn kind(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +#[cfg(test)] +#[path = "steps_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/steps_tests.rs b/.config/jp/tools/src/debug_app/steps_tests.rs new file mode 100644 index 000000000..dd3b8f1fb --- /dev/null +++ b/.config/jp/tools/src/debug_app/steps_tests.rs @@ -0,0 +1,220 @@ +use std::path::{Path, PathBuf}; + +use serde_json::json; + +use super::{DRIVER_VERBS, SNAPSHOT, VOCABULARY, parse}; + +#[test] +fn reads_a_list_of_steps() { + let steps = parse(&json!([ + {"select": {"identifier": "sidebar.row.jp-c12345"}}, + {"wait_for": {"identifier": "transcript.scroll", "timeout_ms": 5000}}, + {"snapshot": {}} + ])) + .unwrap(); + + assert_eq!(steps.len(), 3); + assert_eq!( + steps[0].json(), + r#"{"select":{"identifier":"sidebar.row.jp-c12345"}}"# + ); + assert_eq!( + steps[0].label(), + r#"select {"identifier":"sidebar.row.jp-c12345"}"# + ); + assert!(!steps[0].is_snapshot()); + assert!(steps[2].is_snapshot()); +} + +/// A step with nothing to address reads as its verb alone, rather than as a +/// verb followed by an empty object. +#[test] +fn labels_an_empty_payload_as_the_verb_alone() { + let steps = parse(&json!([{"snapshot": {}}])).unwrap(); + + assert_eq!(steps[0].label(), "snapshot"); + assert_eq!(steps[0].json(), r#"{"snapshot":{}}"#); +} + +/// The driver reads the payload, so an unrecognised key inside one is its error +/// to report, not this parser's. +#[test] +fn passes_an_unrecognised_payload_through_to_the_driver() { + let steps = parse(&json!([{"click": {"identifier": "a", "unknown": 1}}])).unwrap(); + + assert_eq!( + steps[0].json(), + r#"{"click":{"identifier":"a","unknown":1}}"# + ); +} + +/// A list argument sometimes arrives as a JSON string holding the array, which +/// is the list it represents rather than a step named after raw JSON text. +#[test] +fn reads_a_list_that_arrived_as_a_string() { + let steps = parse(&json!(r#"[{"press": {"identifier": "sidebar.filter"}}]"#)).unwrap(); + + assert_eq!(steps.len(), 1); + assert_eq!( + steps[0].json(), + r#"{"press":{"identifier":"sidebar.filter"}}"# + ); +} + +#[test] +fn rejects_a_list_that_is_not_an_array() { + let error = parse(&json!({"select": {"identifier": "a"}})) + .unwrap_err() + .to_string(); + + assert!( + error.starts_with("`steps` is a JSON array of steps, but a object was given."), + "{error}" + ); +} + +#[test] +fn rejects_an_empty_list() { + let error = parse(&json!([])).unwrap_err().to_string(); + + assert!( + error.starts_with("`steps` is empty, so there is nothing to do."), + "{error}" + ); +} + +#[test] +fn rejects_a_step_that_is_not_an_object() { + let error = parse(&json!([{"snapshot": {}}, "click"])) + .unwrap_err() + .to_string(); + + assert!( + error.starts_with("Step 2 is a string, not an object."), + "{error}" + ); +} + +/// Two verbs in one object have no order the driver could run them in, and +/// guessing one would run half of what was written. +#[test] +fn rejects_a_step_naming_two_verbs() { + let error = parse(&json!([{"click": {"identifier": "a"}, "press": {"identifier": "b"}}])) + .unwrap_err() + .to_string(); + + assert!( + error.starts_with("Step 1 names 2 verbs (click, press)."), + "{error}" + ); +} + +#[test] +fn rejects_an_unknown_verb_and_lists_the_known_ones() { + let error = parse(&json!([{"scroll": {"identifier": "a"}}])) + .unwrap_err() + .to_string(); + + assert_eq!( + error, + "Step 1 names an unknown verb `scroll`. A step is a single-key object naming one of: \ + click, drag, menu, perform, press, resize, select, snapshot, type, wait_for. For example \ + `{\"select\": {\"identifier\": \"sidebar.row.jp-c12345\"}}`." + ); +} + +/// The vocabulary has no fixed-duration wait on purpose, so asking for one gets +/// pointed at the predicate that replaces it rather than at the list of verbs. +#[test] +fn rejects_a_sleep_by_naming_what_to_use_instead() { + for verb in ["sleep", "delay", "pause", "wait"] { + let error = parse(&json!([{verb: {"ms": 500}}])) + .unwrap_err() + .to_string(); + + assert_eq!( + error, + format!( + "Step 1 names `{verb}`, and there is no step that waits a fixed duration: waiting \ + and assuming the work finished is a guess. Use `wait_for` against an identifier \ + the app publishes once the work is done. If there is no such identifier, the app \ + is missing one, and adding it is the fix." + ) + ); + } +} + +/// Every error quotes the vocabulary so a caller can correct in place, which +/// only helps if it names the verbs that actually exist. +#[test] +fn the_quoted_vocabulary_names_every_verb() { + for verb in DRIVER_VERBS.iter().chain([&SNAPSHOT]) { + assert!( + VOCABULARY.contains(verb), + "the vocabulary quoted in errors omits `{verb}`: {VOCABULARY}" + ); + } +} + +/// The verbs a tool definition advertises, taken from the bullets in its +/// `steps` description. +fn advertised(tool: &str) -> Vec { + let path = manifest(tool); + let content = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + let value: toml::Value = toml::from_str(&content).expect("valid TOML"); + + let description = value["conversation"]["tools"][tool]["parameters"]["steps"]["description"] + .as_str() + .expect("`steps` has a description"); + + let mut verbs: Vec = description + .lines() + .filter_map(|line| line.strip_prefix("- `{\"")) + .filter_map(|rest| rest.split('"').next()) + .map(str::to_owned) + .collect(); + verbs.sort(); + verbs +} + +fn manifest(tool: &str) -> PathBuf { + let name = tool.trim_start_matches("debug_app_"); + Path::new(env!("CARGO_MANIFEST_DIR")) + .join(format!("../../../.jp/mcp/tools/debug_app/{name}.toml")) +} + +/// The definition is what the model writes a list against, and this parser is +/// what accepts it. +/// A verb advertised but unknown here is a step list refused for naming exactly +/// what it was told to name. +#[test] +fn the_tool_definition_advertises_the_verbs_that_exist() { + let mut every = DRIVER_VERBS.map(str::to_owned).to_vec(); + every.push(SNAPSHOT.to_owned()); + every.sort(); + + pretty_assertions::assert_eq!( + advertised("debug_app_drive"), + every, + "the drive definition is out of sync with the vocabulary" + ); +} + +/// The whole list is checked before anything runs, because a list abandoned +/// halfway leaves the app in a state nobody asked for. +#[test] +fn rejects_the_list_for_a_bad_step_at_the_end() { + let error = parse(&json!([ + {"select": {"identifier": "a"}}, + {"snapshot": {}}, + {"teleport": {}} + ])) + .unwrap_err() + .to_string(); + + assert!( + error.starts_with("Step 3 names an unknown verb `teleport`."), + "{error}" + ); +} diff --git a/.config/jp/tools/src/debug_app/stream.rs b/.config/jp/tools/src/debug_app/stream.rs new file mode 100644 index 000000000..d74d315d3 --- /dev/null +++ b/.config/jp/tools/src/debug_app/stream.rs @@ -0,0 +1,313 @@ +//! The intervals the app timed about itself, read whole rather than summarized. +//! +//! Two sources, one timeline. +//! The live stream in the app's state directory holds this run; the archived +//! streams beside the recordings hold earlier ones. +//! Both are the same JSON-per-line format, every line carries a wall clock, so +//! they concatenate and sort into one sequence that spans every run a slot has +//! kept. +//! +//! Read directly, never through [`Session`]. +//! The offset that tool holds on the stream is what `debug_app_snapshot` uses +//! to report deltas, and a second reader consuming it would silently turn every +//! snapshot's trace section empty. +//! +//! Counts, not milliseconds. +//! View-body evaluations and FFI calls are deterministic for the same steps, so +//! two runs of the same list can be compared on them and a fix can be asserted +//! against them. +//! Wall clock cannot: it is noisy within one run and not comparable between +//! two, which is why every view here leads on a count and carries a duration +//! beside it rather than the other way round. +//! +//! [`Session`]: super::session::Session + +use std::collections::BTreeMap; + +use camino::Utf8Path; +use chrono::DateTime; +use serde_json::Value; + +use crate::{ + Error, + debug_app::{ + capture, + session::{state_dir, trace_path}, + }, + util::trace::{TraceEvent, parse_lines}, +}; + +/// The field an interval's duration arrives in. +const DURATION_FIELD: &str = "duration_ms"; + +/// The field a memory sample arrives in. +const FOOTPRINT_FIELD: &str = "footprint_mb"; + +/// What the app attributes its own work to when it crosses into Rust. +/// +/// Nested inside the Swift interval that caused it, so an FFI count is a count +/// of calls made under the step being measured. +pub(crate) const FFI_TARGET: &str = "JP.FFI"; + +/// Suffix on the name of an interval that timed a view body evaluating. +const BODY_SUFFIX: &str = ".body"; + +/// One interval the app timed. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Interval { + /// What the app called the work. + pub name: String, + + /// What the app attributed it to. + pub target: String, + + /// How long it took. + pub duration_ms: f64, + + /// When it ended, in milliseconds since the epoch. + /// + /// What the app writes: an interval is recorded when it closes. + pub at_ms: u64, + + /// When it began, in milliseconds since the epoch. + /// + /// Derived, because the app records only the end and the duration. + /// This is the moment that attributes the work: an interval belongs to + /// whatever was happening when it started, and a selection that takes 85ms + /// routinely ends after the step that asked for it has been reported. + pub started_ms: u64, + + /// What the process occupied when it ended, in MiB. + pub footprint_mb: Option, + + /// The enclosing interval names, root first. + pub spans: Vec, +} + +impl Interval { + /// Whether this timed a view body evaluating. + pub(crate) fn is_view_body(&self) -> bool { + self.name.ends_with(BODY_SUFFIX) + } + + /// Whether this timed work on the Rust side of the FFI boundary. + pub(crate) fn is_ffi(&self) -> bool { + self.target == FFI_TARGET + } + + /// Whether nothing the app timed encloses this. + pub(crate) fn is_top_level(&self) -> bool { + self.spans.is_empty() + } +} + +/// Every interval a slot has kept, in the order the work began. +/// +/// Archived streams first, then the live one, which is also chronological: a +/// stream is archived at the launch that replaced it. +/// +/// Sorted by start rather than by end, so an enclosing interval reads ahead of +/// the work it contains rather than after it. +pub(crate) fn load(dir: &Utf8Path) -> Vec { + let mut intervals = Vec::new(); + + for path in capture::streams(dir) { + intervals.extend(read(&path)); + } + intervals.extend(read(&trace_path(&state_dir(dir)))); + + intervals.sort_by_key(|interval| interval.started_ms); + intervals +} + +/// The intervals in one stream. +fn read(path: &Utf8Path) -> Vec { + let Ok(raw) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + + parse_lines(&raw).iter().filter_map(interval).collect() +} + +/// One event as an interval, or `None` when it timed nothing. +/// +/// The app writes events that are not intervals — `trace.origin` carries the +/// pair that lines this timeline up with a mach one — and those have no +/// duration. +/// +/// The one place that decides what an interval is, so a snapshot's one-line +/// summary and a report's table cannot disagree about it. +pub(crate) fn interval(event: &TraceEvent) -> Option { + let duration_ms = event.fields.get(DURATION_FIELD).and_then(Value::as_f64)?; + + let at_ms = millis(&event.timestamp)?; + + // Truncated towards zero and floored at the epoch: a duration is milliseconds + // to three decimal places, and the sub-millisecond part cannot move which + // step a start falls in. + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "bounded above by at_ms and below by zero" + )] + let elapsed_ms = duration_ms.max(0.0).trunc() as u64; + + Some(Interval { + name: event.message.clone(), + target: event.target.clone(), + duration_ms, + at_ms, + started_ms: at_ms.saturating_sub(elapsed_ms), + footprint_mb: event.fields.get(FOOTPRINT_FIELD).and_then(Value::as_u64), + spans: event.spans.clone(), + }) +} + +/// An RFC 3339 timestamp as milliseconds since the epoch. +fn millis(timestamp: &str) -> Option { + let parsed = DateTime::parse_from_rfc3339(timestamp).ok()?; + + u64::try_from(parsed.timestamp_millis()).ok() +} + +/// What a set of intervals amounts to. +/// +/// Counts first, because they are what two runs can be compared on. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct Counts { + /// How many intervals there were. + pub intervals: usize, + + /// How many of them timed a view body evaluating. + pub view_bodies: usize, + + /// How many crossed into Rust. + pub ffi_calls: usize, + + /// How long the intervals nothing else encloses took, added up. + /// + /// Only the outermost, so work timed inside another interval is not counted + /// twice. + pub traced_ms: f64, + + /// What the process occupied at the last sample, in MiB. + pub footprint_mb: Option, +} + +/// Reduce `intervals` to what can be compared. +pub(crate) fn count(intervals: &[&Interval]) -> Counts { + let mut counts = Counts::default(); + let mut sampled_at = 0; + + for interval in intervals { + counts.intervals += 1; + + if interval.is_view_body() { + counts.view_bodies += 1; + } + if interval.is_ffi() { + counts.ffi_calls += 1; + } + if interval.is_top_level() { + counts.traced_ms += interval.duration_ms; + } + + // The latest sample by the moment it was taken, which is the end of an + // interval rather than its start. Taking whichever came last in the slice + // would report an enclosed interval's footprint as the enclosing one's, + // since these are ordered by start. + if let Some(footprint) = interval.footprint_mb + && interval.at_ms >= sampled_at + { + counts.footprint_mb = Some(footprint); + sampled_at = interval.at_ms; + } + } + + counts +} + +/// One named piece of work, across every time it ran. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct Tally { + pub count: usize, + pub total_ms: f64, + pub max_ms: f64, +} + +impl Tally { + /// The mean duration, or zero when nothing ran. + pub(crate) fn mean_ms(&self) -> f64 { + if self.count == 0 { + return 0.0; + } + + #[allow(clippy::cast_precision_loss, reason = "display only")] + let count = self.count as f64; + + self.total_ms / count + } +} + +/// Group `intervals` by name, busiest first. +/// +/// Ordered by count rather than by time, which is the ordering an agent can act +/// on: a body evaluating 148 times is a fact about the code, and the 1.1ms it +/// averages is a fact about the machine. +pub(crate) fn tally(intervals: &[&Interval]) -> Vec<(String, Tally)> { + let mut by_name: BTreeMap = BTreeMap::new(); + + for interval in intervals { + let tally = by_name.entry(interval.name.clone()).or_default(); + tally.count += 1; + tally.total_ms += interval.duration_ms; + tally.max_ms = tally.max_ms.max(interval.duration_ms); + } + + let mut out: Vec<(String, Tally)> = by_name.into_iter().collect(); + + // Name breaks the tie, so the same input always renders the same table. + out.sort_by(|(left_name, left), (right_name, right)| { + right + .count + .cmp(&left.count) + .then_with(|| left_name.cmp(right_name)) + }); + out +} + +/// A duration in milliseconds, with a decimal only where one carries meaning. +/// +/// Sub-millisecond work is routine here — a view body evaluating is tens of +/// microseconds — and rounding all of it to `0 ms` would hide which of two +/// bodies is the expensive one. +pub(crate) fn millis_label(duration: f64) -> String { + if duration < 10.0 { + format!("{duration:.1} ms") + } else { + format!("{} ms", duration.round()) + } +} + +/// Whether this slot has a stream at all, live or archived. +/// +/// Distinguishes "the app has never run here" from "the app ran and timed +/// nothing", which are different problems with different fixes. +pub(crate) fn is_present(dir: &Utf8Path) -> bool { + trace_path(&state_dir(dir)).exists() || !capture::streams(dir).is_empty() +} + +/// Fail with what a slot holds instead of a stream. +pub(crate) fn missing(dir: &Utf8Path) -> Error { + format!( + "No traced intervals in {}. The app writes them only when it is launched with a state \ + directory, which `debug_app_launch` does, so this slot has either never run an app or \ + ran one built without the instrumentation.", + state_dir(dir) + ) + .into() +} + +#[cfg(test)] +#[path = "stream_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/stream_tests.rs b/.config/jp/tools/src/debug_app/stream_tests.rs new file mode 100644 index 000000000..c15058d58 --- /dev/null +++ b/.config/jp/tools/src/debug_app/stream_tests.rs @@ -0,0 +1,326 @@ +use std::fs; + +use camino::Utf8Path; +use serde_json::{Value, json}; + +use super::{count, load, millis_label, tally}; +use crate::debug_app::{capture::profiles_dir, session::state_dir}; + +/// One interval, in the shape `Trace.swift` writes. +fn line( + timestamp: &str, + target: &str, + message: &str, + duration_ms: f64, + footprint_mb: u64, +) -> Value { + json!({ + "timestamp": timestamp, + "level": "INFO", + "target": target, + "fields": { + "message": message, + "duration_ms": duration_ms, + "footprint_mb": footprint_mb, + }, + }) +} + +/// The same, nested inside an enclosing interval. +fn nested(timestamp: &str, target: &str, message: &str, duration_ms: f64, span: &str) -> Value { + json!({ + "timestamp": timestamp, + "level": "INFO", + "target": target, + "fields": { "message": message, "duration_ms": duration_ms }, + "spans": [{ "name": span }], + }) +} + +fn stream(lines: &[Value]) -> String { + format!( + "{}\n", + lines + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + ) +} + +fn write_live(dir: &Utf8Path, lines: &[Value]) { + let state = state_dir(dir); + fs::create_dir_all(&state).unwrap(); + fs::write(state.join("trace.jsonl"), stream(lines)).unwrap(); +} + +fn write_archived(dir: &Utf8Path, id: &str, lines: &[Value]) { + fs::create_dir_all(profiles_dir(dir)).unwrap(); + fs::write(profiles_dir(dir).join(format!("{id}.jsonl")), stream(lines)).unwrap(); +} + +/// One line exactly as `Trace.swift` writes it. +/// +/// A raw literal rather than a builder, so the parser is pinned against the +/// format itself rather than against something that agrees with it by +/// construction. +const EXACT_LINE: &str = r#"{"timestamp":"2026-08-03T10:00:00.500000Z","level":"INFO","target":"JP.App","fields":{"message":"conversation.select","duration_ms":12.5,"footprint_mb":184}}"#; + +#[test] +fn an_interval_carries_its_name_target_duration_and_moment() { + let dir = camino_tempfile::tempdir().unwrap(); + let state = state_dir(dir.path()); + fs::create_dir_all(&state).unwrap(); + fs::write(state.join("trace.jsonl"), format!("{EXACT_LINE}\n")).unwrap(); + + let intervals = load(dir.path()); + + assert_eq!(intervals.len(), 1); + assert_eq!(intervals[0].name, "conversation.select"); + assert_eq!(intervals[0].target, "JP.App"); + assert_eq!(format!("{:.1}", intervals[0].duration_ms), "12.5"); + assert_eq!(intervals[0].at_ms, 1_785_751_200_500); + assert_eq!(intervals[0].footprint_mb, Some(184)); + assert!(intervals[0].is_top_level()); +} + +/// The moment work began is what attributes it, and the app records only the +/// moment it ended. +/// A 49ms selection that ends 9ms after the harness closed its step began well +/// inside it. +#[test] +fn an_interval_knows_when_it_began_as_well_as_when_it_ended() { + let dir = camino_tempfile::tempdir().unwrap(); + write_live(dir.path(), &[line( + "2026-08-03T12:12:39.208887Z", + "JP.App", + "conversation.select", + 48.932, + 42, + )]); + + let intervals = load(dir.path()); + + assert_eq!(intervals[0].at_ms, 1_785_759_159_208); + assert_eq!(intervals[0].started_ms, 1_785_759_159_160); +} + +/// Ordered by start, so an enclosing interval reads ahead of the work inside it +/// rather than after it: a parent ends last but begins first. +#[test] +fn intervals_read_in_the_order_the_work_began() { + let dir = camino_tempfile::tempdir().unwrap(); + write_live(dir.path(), &[ + nested( + "2026-08-03T10:00:00.030000Z", + "JP.FFI", + "storage.read", + 10.0, + "conversation.select", + ), + line( + "2026-08-03T10:00:00.100000Z", + "JP.App", + "conversation.select", + 100.0, + 180, + ), + ]); + + let names: Vec = load(dir.path()) + .iter() + .map(|interval| interval.name.clone()) + .collect(); + + assert_eq!(names, vec!["conversation.select", "storage.read"]); +} + +/// `trace.origin` carries the pair that lines this timeline up with a mach one +/// and times nothing, so it is not an interval. +#[test] +fn an_event_with_no_duration_is_not_an_interval() { + let dir = camino_tempfile::tempdir().unwrap(); + write_live(dir.path(), &[ + json!({ + "timestamp": "2026-08-03T10:00:00.000000Z", + "level": "INFO", + "target": "JP.Trace", + "fields": { "message": "trace.origin", "mach_absolute_time": 42 }, + }), + line( + "2026-08-03T10:00:01.000000Z", + "JP.App", + "app.launch", + 900.0, + 120, + ), + ]); + + let intervals = load(dir.path()); + + assert_eq!(intervals.len(), 1); + assert_eq!(intervals[0].name, "app.launch"); +} + +/// Archived streams and the live one are one timeline, which is what makes +/// comparing two runs possible at all. +#[test] +fn archived_and_live_streams_read_as_one_sorted_sequence() { + let dir = camino_tempfile::tempdir().unwrap(); + write_archived(dir.path(), "trace-1000", &[line( + "2026-08-03T09:00:00.000000Z", + "JP.App", + "earlier.run", + 1.0, + 100, + )]); + write_live(dir.path(), &[line( + "2026-08-03T10:00:00.000000Z", + "JP.App", + "this.run", + 2.0, + 110, + )]); + + let names: Vec = load(dir.path()) + .iter() + .map(|interval| interval.name.clone()) + .collect(); + + assert_eq!(names, vec!["earlier.run", "this.run"]); +} + +#[test] +fn counts_lead_and_only_outermost_intervals_add_to_the_traced_total() { + let dir = camino_tempfile::tempdir().unwrap(); + write_live(dir.path(), &[ + line( + "2026-08-03T10:00:00.000000Z", + "JP.App", + "conversation.select", + 100.0, + 180, + ), + nested( + "2026-08-03T10:00:00.010000Z", + "JP.FFI", + "storage.read", + 30.0, + "conversation.select", + ), + nested( + "2026-08-03T10:00:00.020000Z", + "JP.FFI", + "deserialize", + 20.0, + "conversation.select", + ), + line( + "2026-08-03T10:00:00.030000Z", + "JP.App", + "ConversationHistoryView.body", + 0.4, + 182, + ), + ]); + + let intervals = load(dir.path()); + let counts = count(&intervals.iter().collect::>()); + + assert_eq!(counts.intervals, 4); + assert_eq!(counts.view_bodies, 1); + assert_eq!(counts.ffi_calls, 2); + assert_eq!(format!("{:.1}", counts.traced_ms), "100.4"); + assert_eq!(counts.footprint_mb, Some(182)); +} + +/// The footprint is the sample taken last, and a sample is taken when an +/// interval *ends*. +/// Since these are ordered by start, the enclosing interval comes first in the +/// slice and ends last, so taking whichever came last in iteration order would +/// report a nested interval's figure as the enclosing one's. +#[test] +fn the_footprint_is_the_last_sample_taken_rather_than_the_last_one_listed() { + let dir = camino_tempfile::tempdir().unwrap(); + write_live(dir.path(), &[ + line( + "2026-08-03T10:00:00.200000Z", + "JP.App", + "conversation.select", + 100.0, + 250, + ), + line( + "2026-08-03T10:00:00.150000Z", + "JP.App", + "ConversationHistoryView.body", + 1.0, + 190, + ), + ]); + + let intervals = load(dir.path()); + + // The selection begins first and ends last. + assert_eq!(intervals[0].name, "conversation.select"); + assert_eq!( + count(&intervals.iter().collect::>()).footprint_mb, + Some(250) + ); +} + +#[test] +fn a_tally_orders_by_how_often_each_name_ran() { + let dir = camino_tempfile::tempdir().unwrap(); + write_live(dir.path(), &[ + line( + "2026-08-03T10:00:00.000000Z", + "JP.App", + "WorkspaceWindow.body", + 5.0, + 100, + ), + line( + "2026-08-03T10:00:00.001000Z", + "JP.App", + "ConversationHistoryView.body", + 1.0, + 100, + ), + line( + "2026-08-03T10:00:00.002000Z", + "JP.App", + "ConversationHistoryView.body", + 3.0, + 100, + ), + ]); + + let intervals = load(dir.path()); + let tallied = tally(&intervals.iter().collect::>()); + + assert_eq!(tallied.len(), 2); + assert_eq!(tallied[0].0, "ConversationHistoryView.body"); + assert_eq!(tallied[0].1.count, 2); + assert_eq!(format!("{:.1}", tallied[0].1.total_ms), "4.0"); + assert_eq!(format!("{:.1}", tallied[0].1.max_ms), "3.0"); + assert_eq!(format!("{:.1}", tallied[0].1.mean_ms()), "2.0"); + assert_eq!(tallied[1].0, "WorkspaceWindow.body"); +} + +#[test] +fn a_slot_with_no_stream_reads_as_empty() { + let dir = camino_tempfile::tempdir().unwrap(); + + assert_eq!(load(dir.path()), Vec::new()); + assert!(!super::is_present(dir.path())); +} + +/// Sub-millisecond work is routine — a view body is tens of microseconds — +/// and rounding it all to `0 ms` would hide which of two bodies is expensive. +#[test] +fn durations_keep_a_decimal_only_where_one_carries_meaning() { + assert_eq!(millis_label(0.42), "0.4 ms"); + assert_eq!(millis_label(9.96), "10.0 ms"); + assert_eq!(millis_label(1104.4), "1104 ms"); +} diff --git a/.config/jp/tools/src/debug_app/trace.rs b/.config/jp/tools/src/debug_app/trace.rs new file mode 100644 index 000000000..100d21795 --- /dev/null +++ b/.config/jp/tools/src/debug_app/trace.rs @@ -0,0 +1,113 @@ +//! What the app said about its own work, reduced to a few lines. +//! +//! The app writes an interval per named piece of work to `trace.jsonl`, in the +//! same JSON-per-line format `jp` writes, and samples its memory footprint at +//! the end of each one. +//! A snapshot reports that stream the way it reports the console: only what is +//! new, and only as much as fits beside everything else it returns. +//! +//! Summary only, deliberately. +//! A snapshot that dumped a span log would stop being the cheap observation it +//! is used as, and the questions a full log answers — which call site, which +//! pass, what nested what — need a tool of their own. + +use crate::{debug_app::stream, util::trace::TraceEvent}; + +/// What the trace delta amounts to. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct Summary { + /// How many intervals ended in the delta. + pub intervals: usize, + + /// The longest interval, as its name and how long it took. + pub slowest: Option<(String, f64)>, + + /// The most recent footprint sample, in MiB. + pub footprint_mb: Option, + + /// Whether the delta held anything at all. + pub is_empty: bool, +} + +/// Reduce the events written since the last call. +/// +/// Parsed through [`stream::interval`], so what counts as an interval and which +/// fields carry a duration and a footprint are decided in one place. +/// What is summarized differs: this is one line about a delta, and the report +/// is a table over a window. +pub(crate) fn summarize(events: &[TraceEvent]) -> Summary { + let mut summary = Summary { + // The raw events, not the intervals among them: a delta holding only + // events that timed nothing is still a delta, and reporting it as empty + // would say the app did nothing when it did. + is_empty: events.is_empty(), + ..Summary::default() + }; + + for interval in events.iter().filter_map(stream::interval) { + summary.intervals += 1; + + if let Some(footprint) = interval.footprint_mb { + summary.footprint_mb = Some(footprint); + } + + if summary + .slowest + .as_ref() + .is_none_or(|(_, slowest)| interval.duration_ms > *slowest) + { + summary.slowest = Some((interval.name, interval.duration_ms)); + } + } + + summary +} + +/// Render the summary block, or nothing when the app traced nothing. +/// +/// `previous` is the footprint the last snapshot reported, which is what makes +/// the change a change since the caller last looked rather than since the first +/// sample in this delta. +pub(crate) fn render(summary: &Summary, previous: Option) -> Option { + if summary.is_empty { + return None; + } + + let mut block = match (summary.intervals, &summary.slowest) { + (0, _) => "Trace, since the last call: no intervals.".to_owned(), + (count, Some((name, duration))) => format!( + "Trace, since the last call: {count} {}. Slowest `{name}` {}.", + plural(count, "span"), + stream::millis_label(*duration) + ), + (count, None) => format!( + "Trace, since the last call: {count} {}.", + plural(count, "span") + ), + }; + + if let Some(footprint) = summary.footprint_mb { + block.push_str(&format!("\nFootprint {footprint} MB")); + if let Some(change) = previous.map(|before| footprint.cast_signed() - before.cast_signed()) + && change != 0 + { + block.push_str(&format!(" ({change:+} MB)")); + } + block.push('.'); + } + + Some(block) +} + +/// `word` pluralized for `count`. +fn plural(count: usize, word: &str) -> String { + if count == 1 { + word.to_owned() + } else { + format!("{word}s") + } +} + +#[cfg(test)] +#[path = "trace_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/trace_tests.rs b/.config/jp/tools/src/debug_app/trace_tests.rs new file mode 100644 index 000000000..e4cc66618 --- /dev/null +++ b/.config/jp/tools/src/debug_app/trace_tests.rs @@ -0,0 +1,93 @@ +use super::{render, summarize}; +use crate::util::trace::parse_lines; + +/// One line exactly as the app writes it. +/// +/// Pinned here and in `TraceTests.swift`, 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. +const APP_LINE: &str = r#"{"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"}]}"#; + +#[test] +fn summarizes_a_line_the_app_wrote() { + let summary = summarize(&parse_lines(APP_LINE)); + + assert_eq!(summary.intervals, 1); + assert_eq!( + summary.slowest, + Some(("transcript.render".to_owned(), 84.219)) + ); + assert_eq!(summary.footprint_mb, Some(412)); +} + +/// The block a snapshot prints beside its console delta. +#[test] +fn renders_the_slowest_interval_and_the_footprint_change() { + let summary = summarize(&parse_lines(APP_LINE)); + + assert_eq!( + render(&summary, Some(374)).as_deref(), + Some( + "Trace, since the last call: 1 span. Slowest `transcript.render` 84 ms.\nFootprint \ + 412 MB (+38 MB)." + ) + ); +} + +/// The first snapshot of a run has nothing to compare against, and a `(+412 +/// MB)` there would read as a spike rather than as the app's whole footprint. +#[test] +fn omits_the_change_when_nothing_was_reported_before() { + let summary = summarize(&parse_lines(APP_LINE)); + + assert_eq!( + render(&summary, None).as_deref(), + Some( + "Trace, since the last call: 1 span. Slowest `transcript.render` 84 ms.\nFootprint \ + 412 MB." + ) + ); +} + +#[test] +fn counts_every_interval_and_keeps_the_longest() { + let lines = [ + r#"{"timestamp":"2026-08-02T11:04:12.000000Z","level":"INFO","target":"JP.View","fields":{"message":"WorkspaceWindow.body","duration_ms":0.42,"footprint_mb":400}}"#, + r#"{"timestamp":"2026-08-02T11:04:12.100000Z","level":"INFO","target":"JP.View","fields":{"message":"ConversationHistoryView.body","duration_ms":12.5,"footprint_mb":404}}"#, + r#"{"timestamp":"2026-08-02T11:04:12.200000Z","level":"INFO","target":"JP.View","fields":{"message":"WorkspaceWindow.body","duration_ms":0.31,"footprint_mb":405}}"#, + ] + .join("\n"); + + let summary = summarize(&parse_lines(&lines)); + + assert_eq!(summary.intervals, 3); + assert_eq!( + render(&summary, Some(405)).as_deref(), + Some( + "Trace, since the last call: 3 spans. Slowest `ConversationHistoryView.body` 13 \ + ms.\nFootprint 405 MB." + ) + ); +} + +/// The reference pair the app writes at startup times nothing, so a snapshot +/// that only saw it should not claim an interval. +#[test] +fn reports_an_origin_event_as_no_intervals() { + let origin = r#"{"timestamp":"2026-08-02T11:04:10.000000Z","level":"INFO","target":"JP.Trace","fields":{"message":"trace.origin","mach_absolute_time":42000,"unix_time_ns":1785668650000000000,"timebase_numer":125,"timebase_denom":3}}"#; + + let summary = summarize(&parse_lines(origin)); + + assert_eq!(summary.intervals, 0); + assert_eq!( + render(&summary, None).as_deref(), + Some("Trace, since the last call: no intervals.") + ); +} + +/// An app that traced nothing since the last call gets no block at all, rather +/// than a line saying so: the snapshot already carries three other sections. +#[test] +fn renders_nothing_when_the_delta_is_empty() { + assert_eq!(render(&summarize(&parse_lines("")), Some(400)), None); +} diff --git a/.config/jp/tools/src/debug_app/tree.rs b/.config/jp/tools/src/debug_app/tree.rs new file mode 100644 index 000000000..1904645d4 --- /dev/null +++ b/.config/jp/tools/src/debug_app/tree.rs @@ -0,0 +1,220 @@ +//! The application's accessibility tree: how it is asked for, how it arrives, +//! and how it reads. +//! +//! [`read`] shells out to `jpdrive tree`, which answers JSON; [`render`] turns +//! that into one line per element. +//! One line per element is what makes two readings comparable — a selection +//! change moves a `[focused]` marker rather than reflowing a block — so both +//! the snapshot tool and the drive harness report through it. + +use camino::Utf8Path; +use serde::Deserialize; + +use crate::{Error, debug_app::driver, util::runner::ProcessRunner}; + +/// Sibling cap when the caller names none. +/// +/// `jpdrive` defaults to five, which is enough to see the shape of a list but +/// hides the row a caller just selected. +/// Reading everything is the right default for a reading meant to be diffed +/// against another. +pub(crate) const DEFAULT_MAX_SIBLINGS: u32 = 0; + +/// What to read, and how much of it. +#[derive(Debug, Clone, Default)] +pub(crate) struct Options { + /// Keep only elements whose identifier begins with this, and the ancestors + /// leading to them. + pub identifier: Option, + + /// How many matches to find before stopping a filtered read. + pub max_matches: Option, + + /// How deep to walk. + pub depth: Option, + + /// How many children to walk per level, `0` for all of them. + pub max_siblings: u32, + + /// Include each element's on-screen frame. + pub frames: bool, + + /// Include the actions each element advertises. + pub actions: bool, + + /// Walk into the menu bar. + pub menus: bool, +} + +/// The `jpdrive tree` command line. +pub(crate) fn args(pid: u32, opts: &Options) -> Vec { + let mut args = vec![ + "tree".to_owned(), + "--pid".to_owned(), + pid.to_string(), + "--max-siblings".to_owned(), + opts.max_siblings.to_string(), + ]; + + if let Some(prefix) = &opts.identifier { + args.push("--identifier".to_owned()); + args.push(prefix.clone()); + } + + if let Some(matches) = opts.max_matches { + args.push("--max-matches".to_owned()); + args.push(matches.to_string()); + } + + if let Some(depth) = opts.depth { + args.push("--depth".to_owned()); + args.push(depth.to_string()); + } + + if opts.frames { + args.push("--frames".to_owned()); + } + + args +} + +/// The kind the driver reports when a prefix matched nothing. +const NO_MATCH: &str = "identifier_not_found"; + +/// Read the tree of the application running under `pid`. +/// +/// `bin` is the `jpdrive` binary, as [`driver::locate`] found it. +/// +/// `None` means the identifier prefix matched nothing, which is a reading and +/// not a failure: a view part-way through loading holds none of the identifiers +/// it will hold a moment later. +/// A driver that refused for lack of an Accessibility grant carries the +/// diagnosis of that refusal in the error. +pub(crate) fn read( + bin: &Utf8Path, + pid: u32, + opts: &Options, + root: &Utf8Path, + runner: &dyn ProcessRunner, +) -> Result, Error> { + let args = args(pid, opts); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let output = runner + .run(bin.as_str(), &arg_refs, root) + .map_err(|e| format!("Failed to spawn {bin}: {e}"))?; + + // The driver writes results and errors to the same stream, distinguished by + // the exit status. + if !output.success() { + if driver::kind(&output.stdout).as_deref() == Some(NO_MATCH) { + return Ok(None); + } + + return Err(driver::describe_failure( + "tree", + bin, + pid, + root, + runner, + &output.stdout, + &output.stderr, + ) + .into()); + } + + serde_json::from_str(&output.stdout) + .map(Some) + .map_err(|e| format!("Failed to parse the tree `jpdrive` reported: {e}").into()) +} + +/// One element, as `jpdrive tree` reports it. +#[derive(Debug, Deserialize)] +pub(crate) struct TreeNode { + role: String, + identifier: Option, + label: Option, + value: Option, + enabled: Option, + focused: Option, + frame: Option, + #[serde(default)] + actions: Vec, + #[serde(default)] + children: Vec, + elided_children: Option, +} + +/// The whole tree, rendered. +pub(crate) fn rendered(node: &TreeNode, opts: &Options) -> String { + let mut out = String::new(); + render(node, 0, opts, &mut out); + out +} + +/// The role whose subtree is mostly not the app's. +const MENU_BAR_ROLE: &str = "AXMenuBar"; + +/// Render one element and its children, one line each. +/// +/// The menu bar is left unwalked unless asked for. +/// Most of what hangs off it belongs to macOS rather than to the app — the +/// Apple menu, Services, the window tiling submenus — and it runs to some two +/// hundred lines that bury the handful describing the window. +pub(crate) fn render(node: &TreeNode, depth: usize, opts: &Options, out: &mut String) { + out.push_str(&" ".repeat(depth)); + out.push_str(&node.role); + + if let Some(identifier) = &node.identifier { + out.push_str(&format!(" #{identifier}")); + } + + if let Some(label) = &node.label { + out.push_str(&format!(" {label:?}")); + } + + if let Some(value) = &node.value { + out.push_str(&format!(" = {value:?}")); + } + + if node.enabled == Some(false) { + out.push_str(" [disabled]"); + } + + if node.focused == Some(true) { + out.push_str(" [focused]"); + } + + if let Some(frame) = &node.frame { + out.push_str(&format!(" @{frame}")); + } + + if opts.actions && !node.actions.is_empty() { + out.push_str(&format!(" ({})", node.actions.join(", "))); + } + + if let Some(elided) = node.elided_children { + out.push_str(&format!(" (+{elided} not shown)")); + } + + let skip_menus = node.role == MENU_BAR_ROLE && !opts.menus; + if skip_menus && !node.children.is_empty() { + out.push_str(&format!( + " ({} menus not walked, pass `menus` for them)", + node.children.len() + )); + } + + out.push('\n'); + + if skip_menus { + return; + } + + for child in &node.children { + render(child, depth + 1, opts, out); + } +} + +#[cfg(test)] +#[path = "tree_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/debug_app/tree_tests.rs b/.config/jp/tools/src/debug_app/tree_tests.rs new file mode 100644 index 000000000..215e1a83c --- /dev/null +++ b/.config/jp/tools/src/debug_app/tree_tests.rs @@ -0,0 +1,216 @@ +use super::{Options, TreeNode, args, render}; + +/// A tree shaped like the app's: a window, a sidebar holding rows, and a +/// transcript. +/// Trimmed to the keys the renderer reads. +const TREE: &str = r#"{ + "role": "AXApplication", + "actions": [], + "children": [ + { + "role": "AXWindow", + "label": "mac-app", + "actions": ["AXRaise"], + "children": [ + { + "role": "AXTable", + "identifier": "sidebar.conversations", + "label": "Conversations", + "actions": [], + "children": [ + { + "role": "AXRow", + "identifier": "jp-c12345", + "label": "A conversation, 12 events", + "enabled": true, + "focused": true, + "actions": ["AXPress"], + "children": [] + } + ], + "elided_children": 411 + }, + { + "role": "AXButton", + "identifier": "transcript.copy", + "label": "Copy Link", + "enabled": false, + "actions": ["AXPress"], + "children": [] + } + ] + } + ] +}"#; + +fn tree() -> TreeNode { + serde_json::from_str(TREE).unwrap() +} + +/// The rendering is the diff surface, so it is pinned exactly rather than +/// spot-checked: a stray blank line or a reordered marker would make every +/// comparison noisy. +#[test] +fn renders_one_line_per_element() { + let mut out = String::new(); + render(&tree(), 0, &Options::default(), &mut out); + + assert_eq!( + out, + "AXApplication\n AXWindow \"mac-app\"\n AXTable #sidebar.conversations \ + \"Conversations\" (+411 not shown)\n AXRow #jp-c12345 \"A conversation, 12 events\" \ + [focused]\n AXButton #transcript.copy \"Copy Link\" [disabled]\n" + ); +} + +#[test] +fn renders_actions_only_when_asked() { + let opts = Options { + actions: true, + ..Options::default() + }; + let mut out = String::new(); + render(&tree(), 0, &opts, &mut out); + + assert_eq!( + out, + "AXApplication\n AXWindow \"mac-app\" (AXRaise)\n AXTable #sidebar.conversations \ + \"Conversations\" (+411 not shown)\n AXRow #jp-c12345 \"A conversation, 12 events\" \ + [focused] (AXPress)\n AXButton #transcript.copy \"Copy Link\" [disabled] (AXPress)\n" + ); +} + +#[test] +fn renders_a_value_and_a_frame() { + let node: TreeNode = serde_json::from_str( + r#"{"role": "AXTextField", "value": "query", "frame": "0,0 100x20", "actions": [], "children": []}"#, + ) + .unwrap(); + + let opts = Options { + frames: true, + ..Options::default() + }; + let mut out = String::new(); + render(&node, 0, &opts, &mut out); + + assert_eq!(out, "AXTextField = \"query\" @0,0 100x20\n"); +} + +/// Selecting a conversation has to be visible in a diff of two renderings, +/// which is the whole point of one line per element. +#[test] +fn a_selection_change_moves_one_line() { + let before: TreeNode = serde_json::from_str( + r#"{"role": "AXRow", "identifier": "a", "focused": true, "actions": [], "children": []}"#, + ) + .unwrap(); + let after: TreeNode = serde_json::from_str( + r#"{"role": "AXRow", "identifier": "a", "focused": false, "actions": [], "children": []}"#, + ) + .unwrap(); + + let mut first = String::new(); + render(&before, 0, &Options::default(), &mut first); + let mut second = String::new(); + render(&after, 0, &Options::default(), &mut second); + + assert_eq!(first, "AXRow #a [focused]\n"); + assert_eq!(second, "AXRow #a\n"); +} + +/// Left unwalked, the Apple menu, Services, and the window tiling submenus run +/// to some two hundred lines around the handful describing the window. +#[test] +fn leaves_the_menu_bar_unwalked_by_default() { + let node: TreeNode = serde_json::from_str( + r#"{ + "role": "AXApplication", + "actions": [], + "children": [ + {"role": "AXWindow", "label": "mac-app", "actions": [], "children": []}, + { + "role": "AXMenuBar", + "actions": [], + "children": [ + {"role": "AXMenuBarItem", "label": "Apple", "actions": [], "children": []}, + {"role": "AXMenuBarItem", "label": "File", "actions": [], "children": []} + ] + } + ] + }"#, + ) + .unwrap(); + + let mut out = String::new(); + render(&node, 0, &Options::default(), &mut out); + assert_eq!( + out, + "AXApplication\n AXWindow \"mac-app\"\n AXMenuBar (2 menus not walked, pass `menus` for \ + them)\n" + ); + + let opts = Options { + menus: true, + ..Options::default() + }; + let mut walked = String::new(); + render(&node, 0, &opts, &mut walked); + assert_eq!( + walked, + "AXApplication\n AXWindow \"mac-app\"\n AXMenuBar\n AXMenuBarItem \"Apple\"\n \ + AXMenuBarItem \"File\"\n" + ); +} + +/// An app with no menu bar of its own, or one read through a filter that pruned +/// it, must not grow a misleading note. +#[test] +fn says_nothing_about_an_empty_menu_bar() { + let node: TreeNode = + serde_json::from_str(r#"{"role": "AXMenuBar", "actions": [], "children": []}"#).unwrap(); + + let mut out = String::new(); + render(&node, 0, &Options::default(), &mut out); + + assert_eq!(out, "AXMenuBar\n"); +} + +#[test] +fn args_default_to_reading_every_sibling() { + assert_eq!(args(4321, &Options::default()), vec![ + "tree", + "--pid", + "4321", + "--max-siblings", + "0", + ]); +} + +#[test] +fn args_carry_every_option() { + let opts = Options { + identifier: Some("sidebar.".to_owned()), + max_matches: Some(3), + depth: Some(12), + max_siblings: 5, + frames: true, + actions: false, + menus: false, + }; + + assert_eq!(args(4321, &opts), vec![ + "tree", + "--pid", + "4321", + "--max-siblings", + "5", + "--identifier", + "sidebar.", + "--max-matches", + "3", + "--depth", + "12", + "--frames", + ]); +} diff --git a/.config/jp/tools/src/debug_jp/profile_heap.rs b/.config/jp/tools/src/debug_jp/profile_heap.rs index 465c9188b..814ee4f1c 100644 --- a/.config/jp/tools/src/debug_jp/profile_heap.rs +++ b/.config/jp/tools/src/debug_jp/profile_heap.rs @@ -19,7 +19,7 @@ use crate::{ launch::{LaunchSpec, Launcher, RealLauncher, Timeouts}, profile_heap_parse as heap_parse, profile_heap_render as heap_render, sandbox::{Sandbox, SandboxOpts}, - with_termination_note, + shorten_paths, with_termination_note, }, util::{ToolResult, error, runner::DuctProcessRunner}, }; @@ -154,9 +154,9 @@ fn execute( .map_err(|e| format!("Failed to read dhat output at {heap_dst}: {e}"))?; let profile = heap_parse::parse(&json) .map_err(|e| format!("Failed to parse dhat JSON at {heap_dst}: {e}"))?; - let heap_dst_display = crate::debug_jp::util::relative_to(workspace_root, &heap_dst); - let report = heap_render::render(&profile, &launch_result, &spec.args, &heap_dst_display); + let report = heap_render::render(&profile, &launch_result, &spec.args, heap_dst.as_str()); let report = with_termination_note(report, &launch_result); + let report = shorten_paths(&report, workspace_root); fs::write(&report_dst, &report)?; Ok(Outcome::Success { content: report }) diff --git a/.config/jp/tools/src/debug_jp/profile_sampling.rs b/.config/jp/tools/src/debug_jp/profile_sampling.rs index bead87d88..671230d16 100644 --- a/.config/jp/tools/src/debug_jp/profile_sampling.rs +++ b/.config/jp/tools/src/debug_jp/profile_sampling.rs @@ -24,7 +24,7 @@ use crate::{ launch::{LaunchSpec, Launcher, RealLauncher, Timeouts}, profile_sampling_parse as sample_parse, profile_sampling_render as sample_render, sandbox::{Sandbox, SandboxOpts}, - with_termination_note, + shorten_paths, with_termination_note, }, util::{ToolResult, error, runner::DuctProcessRunner}, }; @@ -214,9 +214,9 @@ fn execute( let raw = fs::read_to_string(&sample_path) .map_err(|e| format!("Failed to read sample output at {sample_path}: {e}"))?; let threads = sample_parse::parse(&raw); - let sample_path_display = crate::debug_jp::util::relative_to(workspace_root, &sample_path); - let report = sample_render::render(&threads, &launch_result, &spec.args, &sample_path_display); + let report = sample_render::render(&threads, &launch_result, &spec.args, sample_path.as_str()); let report = with_termination_note(report, &launch_result); + let report = shorten_paths(&report, workspace_root); fs::write(&report_path, &report)?; diff --git a/.config/jp/tools/src/debug_jp/trace.rs b/.config/jp/tools/src/debug_jp/trace.rs index 1ec203139..d66bdf05e 100644 --- a/.config/jp/tools/src/debug_jp/trace.rs +++ b/.config/jp/tools/src/debug_jp/trace.rs @@ -26,11 +26,15 @@ use crate::{ build::{self, BuildSpec}, launch::{LaunchResult, LaunchSpec, Launcher, RealLauncher, Timeouts}, sandbox::{Sandbox, SandboxOpts}, - trace_parse::{self, Level, TRACE_PATH_PREFIX, TraceEvent}, + shorten_paths, trace_render::{self, CommandRun, OutputPaths}, with_termination_note, }, - util::{ToolResult, error, runner::DuctProcessRunner}, + util::{ + ToolResult, error, + runner::DuctProcessRunner, + trace::{self, Level, TRACE_PATH_PREFIX, TraceEvent}, + }, }; /// Tool entrypoint. @@ -259,21 +263,19 @@ fn execute( "", )?; - let trace_display = crate::debug_jp::util::relative_to(workspace_root, &art.trace_dst); - let stdout_display = crate::debug_jp::util::relative_to(workspace_root, &art.stdout_dst); - let stderr_display = crate::debug_jp::util::relative_to(workspace_root, &art.stderr_dst); let report = trace_render::render( &art.events, art.total, &art.launch, &spec.args, OutputPaths { - trace: &trace_display, - stdout: &stdout_display, - stderr: &stderr_display, + trace: art.trace_dst.as_str(), + stdout: art.stdout_dst.as_str(), + stderr: art.stderr_dst.as_str(), }, ); let report = with_termination_note(report, &art.launch); + let report = shorten_paths(&report, workspace_root); fs::write(out_dir.join(format!("report-trace-{ts}.md")), &report)?; Ok(Outcome::Success { content: report }) } @@ -326,9 +328,9 @@ fn execute_sequence( .iter() .map(|a| { ( - crate::debug_jp::util::relative_to(workspace_root, &a.trace_dst), - crate::debug_jp::util::relative_to(workspace_root, &a.stdout_dst), - crate::debug_jp::util::relative_to(workspace_root, &a.stderr_dst), + a.trace_dst.to_string(), + a.stdout_dst.to_string(), + a.stderr_dst.to_string(), ) }) .collect(); @@ -350,7 +352,7 @@ fn execute_sequence( }) .collect(); - let report = trace_render::render_multi(&runs); + let report = shorten_paths(&trace_render::render_multi(&runs), workspace_root); fs::write(out_dir.join(format!("report-trace-{ts}.md")), &report)?; Ok(Outcome::Success { content: report }) } @@ -412,7 +414,7 @@ fn run_one( // marker line or a `trace_log` JSON field, depending on `--format` — and // copy it out of the system temp dir into the real workspace. if !trace_dst.exists() { - let Some(trace_path) = trace_parse::extract_trace_path(&launch_result.stderr) else { + let Some(trace_path) = trace::extract_trace_path(&launch_result.stderr) else { let note = launch_result .note() .map(|n| format!("{n}\n\n")) @@ -438,7 +440,7 @@ fn run_one( let raw = fs::read_to_string(&trace_dst) .map_err(|e| format!("Failed to read trace log at {trace_dst}: {e}"))?; - let all_events = trace_parse::parse_lines(&raw); + let all_events = trace::parse_lines(&raw); let total = all_events.len(); let events = filter_events(all_events, level, target_filter, grep); diff --git a/.config/jp/tools/src/debug_jp/util.rs b/.config/jp/tools/src/debug_jp/util.rs index 2abd83460..2bc68110f 100644 --- a/.config/jp/tools/src/debug_jp/util.rs +++ b/.config/jp/tools/src/debug_jp/util.rs @@ -6,6 +6,8 @@ use camino::Utf8Path; +use crate::util::paths; + pub(crate) mod build; pub(crate) mod launch; pub(crate) mod profile_heap_parse; @@ -13,18 +15,19 @@ pub(crate) mod profile_heap_render; pub(crate) mod profile_sampling_parse; pub(crate) mod profile_sampling_render; pub(crate) mod sandbox; -pub(crate) mod trace_parse; pub(crate) mod trace_render; -/// Render `path` relative to `root` when it lives under it; otherwise return it -/// as-is. +/// Every absolute path in `report`, named by the variable it lives under. /// -/// Used to keep workspace-internal absolute paths out of the reports the tools -/// attach to a conversation — a report showing `tmp/profiling/trace-N.jsonl` -/// reads cleanly regardless of where the workspace lives on disk. -pub(crate) fn relative_to(root: &Utf8Path, path: &Utf8Path) -> String { - path.strip_prefix(root) - .map_or_else(|_| path.to_string(), Utf8Path::to_string) +/// Applied to the finished report rather than to each value that goes into it. +/// These reports quote a subprocess's stderr verbatim, render dhat frames +/// carrying source locations, and print trace fields naming whatever jp was +/// reading — there is no enumerating where a path can turn up, so the whole +/// text gets one pass. +/// That also covers the artifact paths in the footer, which is why nothing +/// upstream of here relativizes anything. +pub(crate) fn shorten_paths(report: &str, root: &Utf8Path) -> String { + paths::shorten_within(report, &paths::shortenings(root)) } /// Prepend a shutdown-warning banner to `report` when jp didn't exit on its own diff --git a/.config/jp/tools/src/debug_jp/util/trace_render.rs b/.config/jp/tools/src/debug_jp/util/trace_render.rs index 59c66a623..ec0f73339 100644 --- a/.config/jp/tools/src/debug_jp/util/trace_render.rs +++ b/.config/jp/tools/src/debug_jp/util/trace_render.rs @@ -30,9 +30,9 @@ use std::{fmt::Write as _, time::Duration}; use serde_json::Value; -use crate::debug_jp::util::{ - launch::LaunchResult, - trace_parse::{self, TraceEvent}, +use crate::{ + debug_jp::util::launch::LaunchResult, + util::trace::{self, TraceEvent}, }; /// Hard ceiling on target column padding. @@ -266,7 +266,7 @@ fn write_multi_footer(out: &mut String, runs: &[CommandRun<'_>]) { fn strip_trace_path_marker(stderr: &str) -> String { stderr .lines() - .filter(|line| !trace_parse::is_trace_path_marker_line(line)) + .filter(|line| !trace::is_trace_path_marker_line(line)) .collect::>() .join("\n") } diff --git a/.config/jp/tools/src/debug_jp/util/trace_render_tests.rs b/.config/jp/tools/src/debug_jp/util/trace_render_tests.rs index 4288e347e..bb7775c9e 100644 --- a/.config/jp/tools/src/debug_jp/util/trace_render_tests.rs +++ b/.config/jp/tools/src/debug_jp/util/trace_render_tests.rs @@ -3,9 +3,9 @@ use std::time::Duration; use serde_json::{Map, Value, json}; use super::*; -use crate::debug_jp::util::{ - launch::Termination, - trace_parse::{Level, TraceEvent}, +use crate::{ + debug_jp::util::launch::Termination, + util::trace::{Level, TraceEvent}, }; fn fixture_launch() -> LaunchResult { diff --git a/.config/jp/tools/src/debug_jp/util_tests.rs b/.config/jp/tools/src/debug_jp/util_tests.rs index 6f7c76005..83c51478b 100644 --- a/.config/jp/tools/src/debug_jp/util_tests.rs +++ b/.config/jp/tools/src/debug_jp/util_tests.rs @@ -1,37 +1,112 @@ use camino::Utf8Path; -use super::relative_to; +use super::shorten_paths; +/// The workspace root, as a report's artifact paths carry it. +const ROOT: &str = "/Users/jean/jp"; + +/// The artifact footer every one of these reports ends with. #[test] -fn relative_to_strips_workspace_prefix() { +fn artifact_paths_in_the_footer_become_relative() { + let report = shorten_paths( + "- Trace: `/Users/jean/jp/tmp/profiling/trace-1.jsonl`\n", + Utf8Path::new(ROOT), + ); + + assert_eq!(report, "- Trace: `tmp/profiling/trace-1.jsonl`\n"); +} + +/// The reason this runs over the whole report rather than over each path that +/// goes into it: a quoted stderr names whatever the subprocess was reading, and +/// nothing upstream knows those strings are paths. +#[test] +fn a_path_quoted_from_a_subprocess_is_shortened_too() { + let report = shorten_paths( + " Error: failed to read /Users/jean/jp/.jp/config.toml\n", + Utf8Path::new(ROOT), + ); + + assert_eq!(report, " Error: failed to read .jp/config.toml\n"); +} + +/// dhat renders a source location inside the frame string, and there is no +/// point at which that is a path rather than prose. +#[test] +fn a_source_location_inside_a_stack_frame_is_shortened() { + let report = shorten_paths( + "> jp_conversation::event::Event::deserialize \ + (/Users/jean/.cargo/registry/src/index.crates.io-1949/serde-1.0/src/de.rs:2025:9)\n", + Utf8Path::new(ROOT), + ); + assert_eq!( - relative_to( - Utf8Path::new("/Users/jean/jp"), - Utf8Path::new("/Users/jean/jp/tmp/profiling/trace.jsonl"), - ), - "tmp/profiling/trace.jsonl" + report, + "> jp_conversation::event::Event::deserialize \ + ($CARGO_HOME/registry/src/index.crates.io-1949/serde-1.0/src/de.rs:2025:9)\n" ); } +/// Several paths on one line, which is the ordinary case for a jp trace event's +/// fields. #[test] -fn relative_to_passes_through_when_outside_workspace() { - // System temp dir, sandbox temp file, etc. — not under the workspace, - // so render the absolute path as-is so the user can find it. - let absolute = "/var/folders/ny/.../T/.tmpXYZ"; +fn every_path_on_a_line_is_shortened() { + let report = shorten_paths( + "INFO config.load path=/Users/jean/jp/.jp/config.toml \ + base=/Users/jean/jp/crates/jp_config\n", + Utf8Path::new(ROOT), + ); + assert_eq!( - relative_to(Utf8Path::new("/Users/jean/jp"), Utf8Path::new(absolute)), - absolute + report, + "INFO config.load path=.jp/config.toml base=crates/jp_config\n" ); } +/// A sandbox lives under the workspace, so its long timestamped path collapses +/// rather than being reported in full on every line that mentions it. #[test] -fn relative_to_passes_through_when_paths_equal() { - // Edge case: the path *is* the root. `strip_prefix` returns an empty - // path here, which would render as an empty string. We still want - // *something* in the report, so the fallback kicks in. - let root = Utf8Path::new("/Users/jean/jp"); - let result = relative_to(root, root); - // Either "" (from strip_prefix) or the original — both are - // technically defensible, but neither should panic. - assert!(result.is_empty() || result == "/Users/jean/jp"); +fn the_sandbox_path_collapses() { + let report = shorten_paths( + "cwd=/Users/jean/jp/tmp/jp-sandbox-1785754546/crates\n", + Utf8Path::new(ROOT), + ); + + assert_eq!(report, "cwd=tmp/jp-sandbox-1785754546/crates\n"); +} + +/// The root on its own still has to render as something: a field whose value +/// vanished reads as a bug in the tool rather than as the workspace root. +#[test] +fn the_root_on_its_own_becomes_a_dot() { + let report = shorten_paths("cwd=/Users/jean/jp\n", Utf8Path::new(ROOT)); + + assert_eq!(report, "cwd=.\n"); +} + +/// A sibling directory whose name merely starts with the root's is a different +/// directory, and the boundary check is the only thing that knows it. +#[test] +fn a_sibling_with_a_longer_name_is_left_alone() { + let report = shorten_paths("cwd=/Users/jean/jp-other/src\n", Utf8Path::new(ROOT)); + + assert_eq!(report, "cwd=$HOME/jp-other/src\n"); +} + +/// A path under nothing known is left alone: the system temp directory is where +/// a sandbox artifact can land, and a reader still has to be able to find it. +#[test] +fn a_path_outside_everything_known_is_left_alone() { + let absolute = "/var/folders/ny/T/.tmpXYZ/trace.jsonl"; + let report = shorten_paths(&format!("- Trace: `{absolute}`\n"), Utf8Path::new(ROOT)); + + assert_eq!(report, format!("- Trace: `{absolute}`\n")); +} + +/// The report is markdown, and shortening must not disturb anything that is not +/// a path. +#[test] +fn text_with_no_paths_is_returned_unchanged() { + let report = "## Hot code\n\n| Self | Share | Symbol |\n| 12 | 4.0% | `core::ptr::drop` |\n"; + + assert_eq!(shorten_paths(report, Utf8Path::new(ROOT)), report); } diff --git a/.config/jp/tools/src/lib.rs b/.config/jp/tools/src/lib.rs index 940aca547..ac1f1429b 100644 --- a/.config/jp/tools/src/lib.rs +++ b/.config/jp/tools/src/lib.rs @@ -1,11 +1,13 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] mod cargo; +mod debug_app; mod debug_jp; mod fs; mod git; mod github; mod plan; +mod swift; mod ticket; mod unix; mod util; @@ -23,9 +25,11 @@ pub async fn run(ctx: Context, t: Tool) -> util::ToolResult { s if s.starts_with("cargo_") => cargo::run(ctx, t).await, s if s.starts_with("github_") => github::run(ctx, t).await, s if s.starts_with("fs_") => fs::run(ctx, t).await, + s if s.starts_with("debug_app_") => debug_app::run(ctx, t).await, s if s.starts_with("debug_jp_") => debug_jp::run(ctx, t).await, s if s.starts_with("web_") => web::run(ctx, t).await, s if s.starts_with("git_") => git::run(ctx, t).await, + s if s.starts_with("swift_") => swift::run(ctx, t).await, s if s.starts_with("unix_") => unix::run(ctx, t), s if s.starts_with("ticket_") => ticket::run(ctx, t), "plan" => plan::run(ctx, t), diff --git a/.config/jp/tools/src/swift.rs b/.config/jp/tools/src/swift.rs new file mode 100644 index 000000000..e617178ca --- /dev/null +++ b/.config/jp/tools/src/swift.rs @@ -0,0 +1,160 @@ +//! Tools for the macOS app in `apps/macos`. +//! +//! These mirror the `cargo_*` tools: each shells out to a toolchain binary from +//! the repository root and reports diagnostics rather than raw build logs. +//! +//! Every tool that builds brings its inputs up to date first, through +//! [`prepare`], so a fresh checkout needs no setup step. + +use jp_tool::Context; + +use crate::{ + Tool, + util::{ + ToolResult, + runner::{ProcessOutput, ProcessRunner}, + truncate, unknown_tool, + }, +}; + +mod check; +mod format; +mod report; +mod test; +mod test_ui; + +use check::swift_check; +use format::swift_format; +use test::swift_test; +use test_ui::swift_test_ui; + +/// Cap for compiler diagnostics embedded in a tool result. +/// +/// `xcodebuild` repeats the failing command line in full for every error, so +/// the tail of a broken build is almost entirely noise. +const MAX_DIAGNOSTIC_BYTES: usize = 32_000; + +/// The generated Xcode project, relative to the repository root. +const PROJECT_PATH: &str = "apps/macos/JP.xcodeproj"; + +/// The `XcodeGen` manifest the project is generated from. +const PROJECT_SPEC: &str = "apps/macos/project.yml"; + +/// The directory the generated project is written into. +const PROJECT_DIR: &str = "apps/macos"; + +/// The scheme covering the app and its test bundle. +const SCHEME: &str = "JP"; + +/// Swift sources formatted and linted by these tools. +const SOURCE_PATHS: &[&str] = &[ + "apps/macos/Sources", + "apps/macos/Tests", + "apps/macos/UITests", + "apps/macos/Tools/jpdrive/Sources", + "apps/macos/Tools/jpdrive/Tests", +]; + +pub async fn run(ctx: Context, t: Tool) -> ToolResult { + match t.name.trim_start_matches("swift_") { + "check" => swift_check(&ctx, t.opt("configuration")?).await, + "test" => swift_test(&ctx, t.opt("testname")?, t.opt("target")?).await, + "test_ui" => swift_test_ui(&ctx, t.opt("tests")?).await, + // `check` is a call parameter, not a tool config option, so it is read + // with `opt` rather than `option_or`. + "format" => swift_format(&ctx, t.opt("check")?.unwrap_or(false)).await, + _ => unknown_tool(t), + } +} + +/// Build the generated inputs an Xcode build depends on. +/// +/// Returns a failure message, or `None` when both steps succeeded. +/// +/// Two things are generated rather than committed: the static library with its +/// C header, and the Xcode project. +/// Both steps are idempotent and cheap when already up to date. +/// +/// The header cannot be left to the project's own build phase. +/// Xcode scans the bridging header while planning the build, before any script +/// phase runs, so a missing header fails the scan rather than triggering the +/// phase that would have produced it. +fn prepare( + ctx: &Context, + profile: &str, + runner: &R, +) -> Result, std::io::Error> { + // Going through `just` keeps the profile-to-directory mapping and the + // cbindgen invocation defined in one place. + let ffi = runner.run("just", &["build-ffi", profile], &ctx.root)?; + if !ffi.status.is_success() { + return Ok(Some(format!( + "Building `jp_ffi` failed:\n\n```\n{}\n```", + report(&ffi, "just build-ffi") + ))); + } + + let project = runner.run( + "xcodegen", + &["generate", "--spec", PROJECT_SPEC, "--project", PROJECT_DIR], + &ctx.root, + )?; + if !project.status.is_success() { + return Ok(Some(format!( + "Generating `{PROJECT_PATH}` failed:\n\n```\n{}\n```\n\nIf xcodegen is not installed, \ + install it with `brew install xcodegen`.", + report(&project, "xcodegen") + ))); + } + + Ok(None) +} + +/// The diagnostics from a process, preferring stdout and falling back to +/// stderr. +/// +/// `xcodebuild` reports compiler diagnostics on stdout and its own failures on +/// stderr; most other tools use stderr for both. +/// `label` names the program for the case where it printed nothing at all. +fn report(output: &ProcessOutput, label: &str) -> String { + let stdout = strip(&output.stdout); + if !stdout.is_empty() { + return stdout; + } + + let stderr = strip(&output.stderr); + if !stderr.is_empty() { + return stderr; + } + + format!( + "{label} exited with status {} and no diagnostics.", + output.status + ) +} + +/// Strip ANSI escapes and trim, capping the result. +fn strip(output: &str) -> String { + let stripped = strip_ansi_escapes::strip_str(output); + truncate(stripped.trim(), MAX_DIAGNOSTIC_BYTES) +} + +/// The message from a failed outcome. +/// +/// Failures come back as `Ok(Outcome::Error { .. })` rather than `Err`, so +/// tests have to reach into the outcome to assert on the message. +/// +/// # Panics +/// +/// Panics if the outcome is not an error. +#[cfg(test)] +fn error_message(outcome: jp_tool::Outcome) -> String { + match outcome { + jp_tool::Outcome::Error { message, .. } => message, + other => panic!("expected an error outcome, got {other:?}"), + } +} + +#[cfg(test)] +#[path = "swift_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/swift/check.rs b/.config/jp/tools/src/swift/check.rs new file mode 100644 index 000000000..5258d2990 --- /dev/null +++ b/.config/jp/tools/src/swift/check.rs @@ -0,0 +1,71 @@ +use jp_tool::Context; + +use super::{PROJECT_PATH, SCHEME, prepare, report, strip}; +use crate::util::{ + ToolResult, error, + runner::{DuctProcessRunner, ProcessRunner}, +}; + +pub(crate) async fn swift_check(ctx: &Context, configuration: Option) -> ToolResult { + swift_check_impl(ctx, configuration.as_deref(), &DuctProcessRunner) +} + +fn swift_check_impl( + ctx: &Context, + configuration: Option<&str>, + runner: &R, +) -> ToolResult { + let configuration = configuration.unwrap_or("Debug"); + // Cargo's profile directory for a configuration, matching `CARGO_PROFILE` in + // the Xcode project. + let profile = if configuration == "Release" { + "release" + } else { + "debug" + }; + + if let Some(failure) = prepare(ctx, profile, runner)? { + return error(failure); + } + + let output = runner.run( + "xcodebuild", + &[ + "build", + "-project", + PROJECT_PATH, + "-scheme", + SCHEME, + "-configuration", + configuration, + "-destination", + "platform=macOS", + // Nothing is run or installed, so skip signing and its keychain + // prompts. + "CODE_SIGNING_ALLOWED=NO", + "-quiet", + ], + &ctx.root, + )?; + + let diagnostics = strip(&output.stdout); + + if output.status.is_success() { + return Ok(if diagnostics.is_empty() { + "Build succeeded. No warnings or errors found." + .to_owned() + .into() + } else { + format!("```\n{diagnostics}\n```\n").into() + }); + } + + error(format!( + "Swift build failed:\n\n```\n{}\n```", + report(&output, "xcodebuild") + )) +} + +#[cfg(test)] +#[path = "check_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/swift/check_tests.rs b/.config/jp/tools/src/swift/check_tests.rs new file mode 100644 index 000000000..a25a429bc --- /dev/null +++ b/.config/jp/tools/src/swift/check_tests.rs @@ -0,0 +1,177 @@ +use jp_tool::{Action, Context}; +use pretty_assertions::assert_eq; + +use super::{super::error_message, *}; +use crate::util::runner::{ExitCode, MockProcessRunner, ProcessOutput}; + +/// The tools pass `ctx.root` straight to the runner and never touch the +/// filesystem, so a fixed path is enough. +fn ctx() -> Context { + Context { + root: "/repo".into(), + action: Action::Run, + access: None, + workspace_id: "test".into(), + conversation_id: "test".into(), + } +} + +/// Expect the two preparation steps every build runs first. +fn prepared(profile: &str) -> MockProcessRunner { + MockProcessRunner::builder() + .expect("just") + .args(&["build-ffi", profile]) + .returns_success("") + .expect("xcodegen") + .args(&[ + "generate", + "--spec", + "apps/macos/project.yml", + "--project", + "apps/macos", + ]) + .returns_success("") +} + +#[test] +fn builds_the_debug_configuration_by_default() { + let runner = prepared("debug") + .expect("xcodebuild") + .args(&[ + "build", + "-project", + "apps/macos/JP.xcodeproj", + "-scheme", + "JP", + "-configuration", + "Debug", + "-destination", + "platform=macOS", + "CODE_SIGNING_ALLOWED=NO", + "-quiet", + ]) + .returns_success(""); + + let result = swift_check_impl(&ctx(), None, &runner).unwrap(); + + assert_eq!( + result.unwrap_content(), + "Build succeeded. No warnings or errors found." + ); +} + +/// A Release build links the library from cargo's `release` directory, so the +/// preparation step has to build that profile rather than the default. +#[test] +fn builds_the_release_library_for_a_release_build() { + let runner = prepared("release") + .expect("xcodebuild") + .args(&[ + "build", + "-project", + "apps/macos/JP.xcodeproj", + "-scheme", + "JP", + "-configuration", + "Release", + "-destination", + "platform=macOS", + "CODE_SIGNING_ALLOWED=NO", + "-quiet", + ]) + .returns_success(""); + + let result = swift_check_impl(&ctx(), Some("Release"), &runner).unwrap(); + + assert_eq!( + result.unwrap_content(), + "Build succeeded. No warnings or errors found." + ); +} + +/// Diagnostics from a passing build still reach the caller: the project treats +/// warnings as errors, so anything reported here is worth reading. +#[test] +fn reports_diagnostics_from_a_passing_build() { + let runner = prepared("debug") + .expect("xcodebuild") + .returns_success("note: some advice from the compiler"); + + let result = swift_check_impl(&ctx(), None, &runner).unwrap(); + + assert_eq!( + result.unwrap_content(), + "```\nnote: some advice from the compiler\n```\n" + ); +} + +/// `xcodebuild` reports compiler diagnostics on stdout, not stderr. +#[test] +fn reports_compiler_errors_from_stdout() { + let runner = prepared("debug") + .expect("xcodebuild") + .returns(ProcessOutput { + stdout: "WorkspaceReader.swift:12:5: error: cannot find 'nope' in scope".to_owned(), + stderr: String::new(), + status: ExitCode::from_code(65), + }); + + let message = error_message(swift_check_impl(&ctx(), None, &runner).unwrap()); + + assert_eq!( + message, + "Swift build failed:\n\n```\nWorkspaceReader.swift:12:5: error: cannot find 'nope' in \ + scope\n```" + ); +} + +/// A non-zero exit with nothing on either stream still has to say something. +#[test] +fn reports_a_bare_failure() { + let runner = prepared("debug") + .expect("xcodebuild") + .returns(ProcessOutput { + stdout: String::new(), + stderr: String::new(), + status: ExitCode::from_code(70), + }); + + let message = error_message(swift_check_impl(&ctx(), None, &runner).unwrap()); + + assert_eq!( + message, + "Swift build failed:\n\n```\nxcodebuild exited with status 70 and no diagnostics.\n```" + ); +} + +/// A failure building the library stops before `xcodebuild`, which would +/// otherwise fail on a missing header and bury the real cause. +#[test] +fn stops_when_the_library_fails_to_build() { + let runner = MockProcessRunner::builder() + .expect("just") + .returns_error("error[E0425]: cannot find value `nope` in this scope"); + + let message = error_message(swift_check_impl(&ctx(), None, &runner).unwrap()); + + assert_eq!( + message, + "Building `jp_ffi` failed:\n\n```\nerror[E0425]: cannot find value `nope` in this \ + scope\n```" + ); +} + +/// xcodegen is the one tool here that is not part of the Swift toolchain, so +/// its absence gets an install hint. +#[test] +fn points_at_homebrew_when_xcodegen_is_missing() { + let runner = MockProcessRunner::builder() + .expect("just") + .returns_success("") + .expect("xcodegen") + .returns_error("command not found: xcodegen"); + + let message = error_message(swift_check_impl(&ctx(), None, &runner).unwrap()); + + assert!(message.contains("brew install xcodegen"), "got: {message}"); +} diff --git a/.config/jp/tools/src/swift/format.rs b/.config/jp/tools/src/swift/format.rs new file mode 100644 index 000000000..c9435b202 --- /dev/null +++ b/.config/jp/tools/src/swift/format.rs @@ -0,0 +1,60 @@ +use jp_tool::Context; + +use super::{SOURCE_PATHS, strip}; +use crate::util::{ + ToolResult, error, + runner::{DuctProcessRunner, ProcessOutput, ProcessRunner}, +}; + +pub(crate) async fn swift_format(ctx: &Context, check: bool) -> ToolResult { + swift_format_impl(ctx, check, &DuctProcessRunner) +} + +fn swift_format_impl(ctx: &Context, check: bool, runner: &R) -> ToolResult { + // `swift format lint` reports rule violations without rewriting; the default + // subcommand rewrites but does not lint. Both read `apps/macos/.swift-format`. + let mut args = if check { + vec!["format", "lint", "--strict"] + } else { + vec!["format", "--in-place"] + }; + args.extend(["--recursive", "--parallel"]); + args.extend(SOURCE_PATHS); + + // `swift format` reports on stderr; stdout carries rewritten source only when + // writing to a pipe, which `--in-place` and `lint` never do. + let ProcessOutput { stderr, status, .. } = runner.run("swift", &args, &ctx.root)?; + + if check { + let findings = strip(&stderr); + return if status.is_success() && findings.is_empty() { + Ok("Swift sources are correctly formatted.".to_owned().into()) + } else { + error(format!( + "Swift formatting or lint violations:\n\n```\n{findings}\n```" + )) + }; + } + + if !status.is_success() { + return error(format!("swift format failed: {}", strip(&stderr))); + } + + // `--in-place` is silent: it neither lists what it rewrote nor says that it + // rewrote nothing. So this reports what was formatted, and claims nothing + // about what changed — `swift_format` with `check` set is what answers that. + let diagnostics = strip(&stderr); + Ok(if diagnostics.is_empty() { + format!("Formatted {}.", SOURCE_PATHS.join(", ")).into() + } else { + format!( + "Formatted {}.\n\n```\n{diagnostics}\n```", + SOURCE_PATHS.join(", ") + ) + .into() + }) +} + +#[cfg(test)] +#[path = "format_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/swift/format_tests.rs b/.config/jp/tools/src/swift/format_tests.rs new file mode 100644 index 000000000..4f12e8094 --- /dev/null +++ b/.config/jp/tools/src/swift/format_tests.rs @@ -0,0 +1,151 @@ +use jp_tool::{Action, Context}; +use pretty_assertions::assert_eq; + +use super::{super::error_message, *}; +use crate::util::runner::{ExitCode, MockProcessRunner, ProcessOutput}; + +fn ctx() -> Context { + Context { + root: "/repo".into(), + action: Action::Run, + access: None, + workspace_id: "test".into(), + conversation_id: "test".into(), + } +} + +#[test] +fn rewrites_sources_in_place() { + let runner = MockProcessRunner::builder() + .expect("swift") + .args(&[ + "format", + "--in-place", + "--recursive", + "--parallel", + "apps/macos/Sources", + "apps/macos/Tests", + "apps/macos/UITests", + "apps/macos/Tools/jpdrive/Sources", + "apps/macos/Tools/jpdrive/Tests", + ]) + .returns_success(""); + + let result = swift_format_impl(&ctx(), false, &runner).unwrap(); + + assert_eq!( + result.unwrap_content(), + "Formatted apps/macos/Sources, apps/macos/Tests, apps/macos/UITests, \ + apps/macos/Tools/jpdrive/Sources, apps/macos/Tools/jpdrive/Tests." + ); +} + +/// `swift format --in-place` says nothing at all, whether it rewrote every file +/// or none. +/// Claiming "no files to format" from that silence would be a guess, and a +/// wrong one whenever it did rewrite something. +#[test] +fn does_not_claim_nothing_changed() { + let runner = MockProcessRunner::success(""); + + let result = swift_format_impl(&ctx(), false, &runner).unwrap(); + + assert!( + !result.unwrap_content().contains("No files"), + "the formatter cannot know whether anything changed, so it must not say" + ); +} + +/// Anything the formatter does say is passed along, since it only speaks up +/// when something is wrong. +#[test] +fn relays_what_the_formatter_reported() { + let runner = MockProcessRunner::builder() + .expect("swift") + .returns(ProcessOutput { + stdout: String::new(), + stderr: "JPApp.swift:3:1: warning: [Indentation] unexpected indentation".to_owned(), + status: ExitCode::success(), + }); + + let result = swift_format_impl(&ctx(), false, &runner).unwrap(); + + assert!( + result.unwrap_content().contains("unexpected indentation"), + "expected the formatter's own output to be relayed" + ); +} + +#[test] +fn lints_without_rewriting_in_check_mode() { + let runner = MockProcessRunner::builder() + .expect("swift") + .args(&[ + "format", + "lint", + "--strict", + "--recursive", + "--parallel", + "apps/macos/Sources", + "apps/macos/Tests", + "apps/macos/UITests", + "apps/macos/Tools/jpdrive/Sources", + "apps/macos/Tools/jpdrive/Tests", + ]) + .returns_success(""); + + let result = swift_format_impl(&ctx(), true, &runner).unwrap(); + + assert_eq!( + result.unwrap_content(), + "Swift sources are correctly formatted." + ); +} + +#[test] +fn reports_lint_violations_in_check_mode() { + let runner = MockProcessRunner::builder() + .expect("swift") + .returns(ProcessOutput { + stdout: String::new(), + stderr: "JPApp.swift:3:1: warning: [NeverForceUnwrap] do not force unwrap".to_owned(), + status: ExitCode::from_code(1), + }); + + let message = error_message(swift_format_impl(&ctx(), true, &runner).unwrap()); + + assert_eq!( + message, + "Swift formatting or lint violations:\n\n```\nJPApp.swift:3:1: warning: \ + [NeverForceUnwrap] do not force unwrap\n```" + ); +} + +/// A clean exit with findings on stderr is still a violation: `--strict` makes +/// the findings meaningful, and trusting the exit status alone would hide them. +#[test] +fn treats_findings_as_violations_even_on_a_clean_exit() { + let runner = MockProcessRunner::builder() + .expect("swift") + .returns(ProcessOutput { + stdout: String::new(), + stderr: "JPApp.swift:3:1: warning: [Indentation] unexpected indentation".to_owned(), + status: ExitCode::success(), + }); + + let message = error_message(swift_format_impl(&ctx(), true, &runner).unwrap()); + + assert!(message.contains("Indentation"), "got: {message}"); +} + +#[test] +fn reports_a_formatter_failure() { + let runner = MockProcessRunner::error("error: unknown option '--nope'"); + + let message = error_message(swift_format_impl(&ctx(), false, &runner).unwrap()); + + assert_eq!( + message, + "swift format failed: error: unknown option '--nope'" + ); +} diff --git a/.config/jp/tools/src/swift/report.rs b/.config/jp/tools/src/swift/report.rs new file mode 100644 index 000000000..27c4fc5d5 --- /dev/null +++ b/.config/jp/tools/src/swift/report.rs @@ -0,0 +1,612 @@ +//! Turning a test runner's output into something worth reading. +//! +//! Shared by [`swift_test`] and [`swift_test_ui`], which run different bundles +//! through different filters but report their results the same way. +//! +//! [`swift_test_ui`]: super::test_ui +//! [`swift_test`]: super::test + +use std::fs; + +use camino::{Utf8Path, Utf8PathBuf}; +use jp_tool::Context; +use serde_json::Value; + +use super::strip; +use crate::util::runner::{ProcessOutput, ProcessRunner}; + +/// How much of the raw log to show when there is no summary to show instead. +const LOG_TAIL_LINES: usize = 40; + +/// The app's unit-test bundle, hosted by the app process. +const UNIT_BUNDLE: &str = "JPTests"; + +/// The app's UI-test bundle, which drives the app from outside it. +pub(super) const UI_BUNDLE: &str = "JPUITests"; + +/// An `-only-testing` argument addressing the unit bundle. +/// +/// `-only-testing` takes `//`; the bundle alone narrows a +/// run to everything in it. +pub(super) fn unit_bundle_filter(testname: Option<&str>) -> String { + match testname { + Some(name) => format!("-only-testing:{UNIT_BUNDLE}/{name}"), + None => format!("-only-testing:{UNIT_BUNDLE}"), + } +} + +/// An `-only-testing` argument addressing one test or suite in the UI bundle. +pub(super) fn ui_bundle_filter(test: &str) -> String { + format!("-only-testing:{UI_BUNDLE}/{test}") +} + +/// The labelled summary of a run, or the message explaining why it is not one. +/// +/// A run that reported no summary is not a passing run, it is a run that did +/// nothing: every runner here can exit zero when a filter matches nothing at +/// all. +pub(super) fn outcome(output: &ProcessOutput, label: &str) -> Result { + // Scan the whole log before capping it. A non-quiet run is far longer than + // the diagnostic cap and the summary is at the end, so truncating first would + // throw away the only lines worth reading. + let log = Log::from(output); + let summary = log.summary(); + + if !output.status.is_success() { + let detail = [log.crashes(), log.failures(), summary] + .into_iter() + .filter(|section| !section.is_empty()) + .collect::>() + .join("\n"); + + if !detail.is_empty() { + return Err(format!( + "{label} tests failed:\n\n```\n{}\n```", + strip(&detail) + )); + } + + // Neither a failing test nor a summary means the run did not get far enough + // to report either. Showing the head of the log here would show the build + // starting; whatever went wrong is at the other end. + return Err(format!( + "{label} tests failed without reporting a failing test or a summary, so the run died \ + rather than failing. The end of its output:\n\n```\n{}\n```", + log.tail(LOG_TAIL_LINES) + )); + } + + if summary.is_empty() { + return Err(format!( + "{label} exited successfully but reported no test summary, so no test ran. A name \ + that matches nothing in the bundle it was pointed at will do this. The end of its \ + output:\n\n```\n{}\n```", + log.tail(LOG_TAIL_LINES) + )); + } + + // A run can cover more than one bundle and so report more than one summary. + // Labelling each keeps an unlabelled second line from reading as output + // from somewhere else. + Ok(summary + .lines() + .map(|line| format!("{label}: {line}")) + .collect::>() + .join("\n")) +} + +/// The UI test bundle's identifier, as `apps/macos/project.yml` sets it. +const UI_BUNDLE_ID: &str = "computer.jp.jean-pierre.uitests"; + +/// Where collected screenshots are put, relative to the repository root. +const SCREENSHOT_DIR: &str = "tmp/uitests"; + +/// Where the UI test runner writes its failure screenshots. +/// +/// Xcode wraps a UI test bundle in a generated, sandboxed runner app, so the +/// tests cannot write into the checkout and put their screenshots in the +/// container's temporary directory instead. +/// Nothing reports that path, so it is derived the same way the runner does: +/// from its bundle identifier, which is the test bundle's with `.xctrunner` +/// appended. +fn screenshot_source() -> Option { + let home = std::env::var("HOME").ok()?; + + Some(Utf8PathBuf::from(home).join(format!( + "Library/Containers/{UI_BUNDLE_ID}.xctrunner/Data/tmp/jp-uitests" + ))) +} + +/// Discard screenshots left by an earlier run. +pub(super) fn clear_screenshots() { + if let Some(dir) = screenshot_source() { + let _removed = fs::remove_dir_all(&dir); + } +} + +/// The file the UI tests write their failure messages to. +const FAILURE_LOG: &str = "failures.txt"; + +/// Where `xcodebuild` is told to write the result bundle for a UI run. +/// +/// Inside the checkout and under `tmp/`, so it is reachable without deriving a +/// container path and is thrown away with the rest of the scratch directory. +pub(super) const RESULT_BUNDLE: &str = "tmp/uitests/run.xcresult"; + +/// Discard the result bundle an earlier run left behind. +/// +/// `xcodebuild` refuses to write over an existing bundle, so this is not +/// tidying up: without it the second UI run in a checkout fails before it +/// starts. +pub(super) fn clear_result_bundle(root: &Utf8Path) { + let _removed = fs::remove_dir_all(root.join(RESULT_BUNDLE)); +} + +/// The file the test runner's own output is staged into. +const RUNNER_OUTPUT: &str = "StandardOutputAndStandardError.txt"; + +/// The line swift-testing writes when an expectation fails. +const ISSUE_MARKER: &str = "recorded an issue at"; + +/// What the tests reported, taken from the runner's output in the result +/// bundle. +/// +/// The bundle is written incrementally into a `Staging` directory and only +/// sealed at the end of a run, so `xcresulttool` cannot open one belonging to a +/// run that was stopped at its first failure — which is every run outside CI. +/// The staged runner output is plain text and is there either way, and it holds +/// what swift-testing printed: the failed expression, and the comment the +/// author wrote under it. +/// +/// Nothing in the test target has to cooperate. +/// A `#expect`, a `#require`, an `Issue.record` and an `XCTest` assertion all +/// arrive the same way, which is the property a convention nobody has to +/// remember gives you. +pub(super) fn collect_staged_issues(root: &Utf8Path) -> Option { + let mut issues = Vec::new(); + + for file in staged_runner_output(&root.join(RESULT_BUNDLE)) { + let Ok(text) = fs::read_to_string(&file) else { + continue; + }; + + issues.extend(issue_lines(&text)); + } + + if issues.is_empty() { + return None; + } + + Some(format!( + "\n\nWhat the tests reported:\n\n```\n{}\n```\n", + issues.join("\n") + )) +} + +/// The failed expectations in one runner log, each with the comment under it. +/// +/// A failure is one line naming where it happened and what did not hold, then +/// any number of lines carrying the author's comment. +/// The comment lines are recognized by what they are not: the runner prefixes +/// its own activity with a timestamp, and marks suites and tests with a glyph. +fn issue_lines(text: &str) -> Vec { + let mut found: Vec = Vec::new(); + let mut in_issue = false; + + for line in text.lines() { + let trimmed = line.trim(); + + if trimmed.contains(ISSUE_MARKER) { + found.push(strip(trimmed)); + in_issue = true; + continue; + } + + if !in_issue { + continue; + } + + // The runner's own activity resumes, so the comment has ended. + if trimmed.is_empty() || trimmed.starts_with("t =") { + in_issue = false; + continue; + } + + let comment = strip(trimmed); + // Source lines the runner echoes back are already in the message above. + if comment.starts_with("//") { + continue; + } + + found.push(format!(" {comment}")); + } + + found +} + +/// Every staged runner log inside a result bundle. +/// +/// The path holds two UUIDs the run picks, so the tree is walked rather than +/// spelled out. +fn staged_runner_output(bundle: &Utf8Path) -> Vec { + let mut found = Vec::new(); + let mut stack = vec![bundle.join("Staging")]; + + while let Some(directory) = stack.pop() { + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + + for entry in entries.flatten() { + let Ok(path) = Utf8PathBuf::from_path_buf(entry.path()) else { + continue; + }; + + if path.is_dir() { + stack.push(path); + } else if path.file_name() == Some(RUNNER_OUTPUT) { + found.push(path); + } + } + } + + found +} + +/// What the tests reported, read from the result bundle `xcodebuild` wrote. +/// +/// Apple's own reader for Apple's own format, so a failing `#expect`, a +/// `#require`, an `Issue.record` and an `XCTest` assertion all arrive the same +/// way, with the comment the author wrote. +/// Nothing in the test target has to know about this, which is the point: a +/// helper the suite must remember to call is a helper a future test will +/// forget. +/// +/// Returns `None` when there is no readable bundle. +/// A run stopped at its first failure is killed rather than asked to stop, so +/// the bundle it was part-way through writing may not be finished — which is +/// why the caller keeps a fallback rather than relying on this alone. +pub(super) fn collect_bundle_issues(ctx: &Context, runner: &R) -> Option { + let bundle = ctx.root.join(RESULT_BUNDLE); + if !bundle.exists() { + return None; + } + + let output = runner + .run( + "xcrun", + &[ + "xcresulttool", + "get", + "test-results", + "tests", + "--path", + bundle.as_str(), + "--compact", + ], + &ctx.root, + ) + .ok()?; + + let document: Value = serde_json::from_str(&output.stdout).ok()?; + let mut failures = Vec::new(); + walk_failures(&document, &mut Vec::new(), &mut failures); + + if failures.is_empty() { + return None; + } + + Some(format!( + "\n\nWhat the tests reported, from {RESULT_BUNDLE}:\n\n```\n{}\n```\n", + failures.join("\n") + )) +} + +/// Collect every failure message in the tests tree, under the test that holds +/// it. +/// +/// The JSON is walked for the shapes it is known to use rather than +/// deserialized into the document's schema. +/// `xcresulttool` versions its output and has changed it between Xcode +/// releases; a walk that finds nothing degrades to a less helpful message, +/// where a failed parse would replace the failure being reported with a +/// complaint about reading it. +fn walk_failures(value: &Value, path: &mut Vec, found: &mut Vec) { + if let Value::Array(items) = value { + for item in items { + walk_failures(item, path, found); + } + return; + } + + let Some(object) = value.as_object() else { + return; + }; + + let kind = object.get("nodeType").and_then(Value::as_str).unwrap_or(""); + let name = object.get("name").and_then(Value::as_str).unwrap_or(""); + + // A failure is a node whose type says so, and whose name is the message. + if kind.contains("Failure") && !name.is_empty() { + let where_ = path.last().map_or("", String::as_str); + found.push(format!("{where_}: {name}")); + } + + // Test cases name themselves, so the innermost one seen above a failure is + // the test that recorded it. + let named = kind == "Test Case" && !name.is_empty(); + if named { + path.push(name.to_owned()); + } + + for child in object.values() { + walk_failures(child, path, found); + } + + if named { + path.pop(); + } +} + +/// The file the UI tests write the process ids of the apps they launched to. +const APP_PIDS: &str = "app.pids"; + +/// Close the apps a stopped run left running. +/// +/// Stopping a run kills `xcodebuild`, and that does not reach the app under +/// test: `testmanagerd` launched it, so it survives and sits on the screen +/// until somebody quits it. +/// +/// By process id, written by each app itself, and never by name or bundle +/// identifier — the developer's own copy of JP has both of those, and closing +/// their window because a test failed would be a poor trade for a tidy screen. +/// A `TERM` rather than a kill, so the app puts itself away as it would on +/// quit. +pub(super) fn close_leftover_apps() { + let Some(source) = screenshot_source() else { + return; + }; + + let Ok(pids) = fs::read_to_string(source.join(APP_PIDS)) else { + return; + }; + + for pid in pids.lines().map(str::trim).filter(|pid| !pid.is_empty()) { + // Most of these are already gone: a test that finished terminated its + // own app. Signalling a process that is not there fails, which is the + // answer wanted anyway. + let _signalled = std::process::Command::new("kill") + .args(["-TERM", pid]) + .status(); + } + + let _removed = fs::remove_file(source.join(APP_PIDS)); +} + +/// What the UI tests recorded about their own failures. +/// +/// Returns the empty string when they recorded nothing. +/// +/// This exists because `xcodebuild` prints the header of a swift-testing issue +/// and drops the message under it, so a failing run arrives as a column of +/// identical `Issue recorded` lines. +/// The tests write the messages themselves, and this is where they are read +/// back. +pub(super) fn collect_failures() -> String { + let Some(source) = screenshot_source() else { + return "\n\nThe tests' own failure messages could not be looked for: HOME is unset, so \ + the runner's container has no derivable path.\n" + .to_owned(); + }; + + let path = source.join(FAILURE_LOG); + let log = match fs::read_to_string(&path) { + Ok(log) if !log.trim().is_empty() => log, + + // Read and empty, or not there at all. Both mean the same thing to a + // reader and neither may be reported as silence: a run that failed while + // writing nothing here looks, without this, exactly like a run that + // failed for no stated reason. + _ => { + return format!( + "\n\nThe tests recorded no failure messages ({path} is empty or absent). A \ + failing `#expect` or `#require` writes nothing here on its own \u{2014} only the \ + helpers that call `AppUnderTest.record` do. Read the assertion at the reported \ + line, or route it through `expectAppears`, `expectTranscript` or `record` so the \ + next run says what it saw.\n" + ); + } + }; + + format!( + "\n\nWhat the tests recorded:\n\n```\n{}\n```\n", + log.trim_end() + ) +} + +/// Copy this run's screenshots into the checkout and name them. +/// +/// Returns the empty string when there are none, so a failure with nothing to +/// show reads exactly as it did before. +/// +/// Copied rather than linked to: the container directory is emptied at the +/// start of every run, and a path that stops resolving the moment the next run +/// starts is worse than no path. +pub(super) fn collect_screenshots(root: &Utf8Path) -> String { + let Some(source) = screenshot_source() else { + return String::new(); + }; + + let target = root.join(SCREENSHOT_DIR); + let mut collected = Vec::new(); + + let Ok(entries) = fs::read_dir(&source) else { + return String::new(); + }; + + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if !Utf8Path::new(name) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("png")) + { + continue; + } + + if fs::create_dir_all(&target).is_err() { + return String::new(); + } + + if fs::copy(entry.path(), target.join(name)).is_ok() { + collected.push(format!("{SCREENSHOT_DIR}/{name}")); + } + } + + if collected.is_empty() { + return String::new(); + } + + collected.sort(); + format!( + "\n\nWhat was on screen when the assertions failed. A tool result is text, so attach one \ + to see it:\n\n{}\n", + collected + .iter() + .map(|path| format!("- `{path}`")) + .collect::>() + .join("\n") + ) +} + +/// Markers of a run that died rather than one that failed. +/// +/// A process that crashes reports no failing test and no summary, so without +/// these the only honest thing left to say is "something went wrong, here is +/// the log". +/// `xcodebuild` announces a dead test runner; the rest are what the runtime +/// prints on its way out. +const CRASH_MARKERS: &[&str] = &[ + "Restarting after unexpected exit", + "Fatal error", + "Crashed:", + "EXC_BAD_ACCESS", + "Test runner exited", +]; + +/// A test run's output, ready to be searched. +/// +/// Both runners split their output across both streams, so both are kept. +struct Log { + stdout: String, + stderr: String, +} + +impl Log { + fn from(output: &ProcessOutput) -> Self { + Self { + stdout: strip_ansi_escapes::strip_str(&output.stdout), + stderr: strip_ansi_escapes::strip_str(&output.stderr), + } + } + + /// Both streams, one line at a time, exactly as written. + fn raw_lines(&self) -> impl Iterator { + self.stdout.lines().chain(self.stderr.lines()) + } + + fn lines(&self) -> impl Iterator { + self.raw_lines().map(clean) + } + + /// The run summary, naming how many tests ran. + /// + /// Empty when nothing ran, which is a distinct outcome from a passing run. + fn summary(&self) -> String { + strip( + &self + .lines() + .filter(|line| is_run_summary(line)) + .collect::>() + .join("\n"), + ) + } + + /// The lines saying the run died rather than reported a failure. + fn crashes(&self) -> String { + strip( + &self + .lines() + .filter(|line| CRASH_MARKERS.iter().any(|marker| line.contains(marker))) + .collect::>() + .join("\n"), + ) + } + + /// The last `count` non-empty lines of both streams. + /// + /// The end rather than the beginning: a run with no diagnostic to show got + /// as far as it got, and the head of the log is the build starting up. + fn tail(&self, count: usize) -> String { + let lines: Vec<&str> = self.lines().filter(|line| !line.is_empty()).collect(); + let start = lines.len().saturating_sub(count); + + strip(&lines[start..].join("\n")) + } + + /// The lines naming what went wrong. + /// + /// Two spellings of a compiler error, because the two runners differ: + /// `xcodebuild` prefixes the file and line, and `swift test` reports a + /// driver-level failure such as a missing module with nothing in front of + /// it. + /// + /// Continuation lines come along with the failure they belong to. + /// swift-testing puts an issue's own message on a line of its own, under a + /// header that names only the *kind* of issue, so a filter that kept the + /// header alone would report every recorded issue as "Issue recorded" and + /// throw away the sentence explaining it. + fn failures(&self) -> String { + strip( + &self + .raw_lines() + .filter(|line| { + let cleaned = clean(line); + line.contains(ISSUE_DETAIL_MARKER) + || cleaned.contains("recorded an issue") + || cleaned.contains(": error:") + || cleaned.starts_with("error:") + }) + .map(clean) + .collect::>() + .join("\n"), + ) + } +} + +/// The character swift-testing indents an issue's message with. +const ISSUE_DETAIL_MARKER: char = '\u{21b3}'; + +/// A log line with its decoration removed. +/// +/// swift-testing prefixes each line with an SF Symbol from a private use area, +/// which is a glyph in Xcode and mojibake anywhere else. +fn clean(line: &str) -> &str { + line.trim_start_matches(|c: char| !c.is_ascii()).trim() +} + +/// Whether a line summarizes a run that executed at least one test. +/// +/// A zero count is not a summary but the absence of one: a bundle whose tests +/// are all swift-testing always draws an `Executed 0 tests` line from the +/// `XCTest` runner, and reporting that as a pass would hide every mistyped +/// filter. +fn is_run_summary(line: &str) -> bool { + // swift-testing: "Test run with 30 tests in 6 suites passed after 0.028s." + if line.contains("Test run with") { + return !line.contains("with 0 tests"); + } + + // XCTest: "Executed 20 tests, with 0 failures (0 unexpected) in 0.1 seconds" + line.contains("Executed ") && !line.contains("Executed 0 tests") +} diff --git a/.config/jp/tools/src/swift/test.rs b/.config/jp/tools/src/swift/test.rs new file mode 100644 index 000000000..8012cc9b2 --- /dev/null +++ b/.config/jp/tools/src/swift/test.rs @@ -0,0 +1,162 @@ +use jp_tool::Context; + +use super::{ + PROJECT_PATH, SCHEME, prepare, + report::{outcome, unit_bundle_filter}, +}; +use crate::util::{ + ToolResult, error, + runner::{DuctProcessRunner, ProcessOutput, ProcessRunner}, +}; + +/// The `SwiftPM` package holding the accessibility driver. +const DRIVE_PACKAGE: &str = "apps/macos/Tools/jpdrive"; + +/// Which test suites to run. +/// +/// The two are built and filtered by different tools, so a run has to name +/// which it means rather than passing one filter to both: a suite name that +/// addresses the app matches nothing in the package, and a run that matched +/// nothing is reported as a failure. +/// +/// The UI tests are not reachable from here at all. +/// They launch the app and take the screen for as long as they run, so they +/// belong to `swift_test_ui`, which has to be asked for them by name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Target { + /// The app's unit-test bundle, through `xcodebuild`. + App, + /// The driver package's tests, through `swift test`. + Drive, + /// Both. + All, +} + +impl Target { + fn parse(value: Option<&str>) -> Result { + match value { + None | Some("all") => Ok(Self::All), + Some("app") => Ok(Self::App), + Some("drive") => Ok(Self::Drive), + Some("ui") => Err( + "the UI tests run through `swift_test_ui`, which takes the tests to run by name. \ + They drive the app through the screen, so a run costs about six seconds per test \ + and takes over the display." + .to_owned(), + ), + Some(other) => Err(format!( + "unknown target '{other}', expected one of: app, drive, all" + )), + } + } + + fn includes_app(self) -> bool { + self != Self::Drive + } + + fn includes_drive(self) -> bool { + self != Self::App + } +} + +pub(crate) async fn swift_test( + ctx: &Context, + testname: Option, + target: Option, +) -> ToolResult { + swift_test_impl( + ctx, + testname.as_deref(), + target.as_deref(), + &DuctProcessRunner, + ) +} + +fn swift_test_impl( + ctx: &Context, + testname: Option<&str>, + target: Option<&str>, + runner: &R, +) -> ToolResult { + let target = match Target::parse(target) { + Ok(target) => target, + Err(message) => return error(message), + }; + + let mut summaries = Vec::new(); + + // The driver package needs no Xcode project and runs in milliseconds, so it + // goes first: a failure there is reported before a minute of `xcodebuild`. + if target.includes_drive() { + let output = run_drive(ctx, testname, runner)?; + match outcome(&output, "DriveKit") { + Ok(summary) => summaries.push(summary), + Err(message) => return error(message), + } + } + + if target.includes_app() { + // Tests run against the Debug configuration, so the library they link is + // the one in cargo's `debug` directory. + if let Some(failure) = prepare(ctx, "debug", runner)? { + return error(failure); + } + + let output = run_app(ctx, testname, runner)?; + match outcome(&output, "JP") { + Ok(summary) => summaries.push(summary), + Err(message) => return error(message), + } + } + + Ok(format!("```\n{}\n```", summaries.join("\n")).into()) +} + +/// Run the driver package's tests. +fn run_drive( + ctx: &Context, + testname: Option<&str>, + runner: &R, +) -> Result { + let mut args = vec!["test", "--package-path", DRIVE_PACKAGE]; + if let Some(testname) = testname { + // `swift test` takes a regex over `Suite/test`, which is the same shape + // callers already write for the app bundle. + args.extend(["--filter", testname]); + } + + runner.run("swift", &args, &ctx.root) +} + +/// Run the app's unit-test bundle. +/// +/// The filter always names the bundle, even with no test to narrow to: the UI +/// bundle is in the same scheme, and a run that named no bundle would launch +/// the app once per UI test. +fn run_app( + ctx: &Context, + testname: Option<&str>, + runner: &R, +) -> Result { + let filter = unit_bundle_filter(testname); + + // `-quiet` is deliberately absent. It suppresses the test summary along with + // everything else, which makes a filter that matched nothing look exactly + // like a passing run. + let args = vec![ + "test", + "-project", + PROJECT_PATH, + "-scheme", + SCHEME, + "-destination", + "platform=macOS", + &filter, + ]; + + runner.run("xcodebuild", &args, &ctx.root) +} + +#[cfg(test)] +#[path = "test_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/swift/test_tests.rs b/.config/jp/tools/src/swift/test_tests.rs new file mode 100644 index 000000000..6a058fad4 --- /dev/null +++ b/.config/jp/tools/src/swift/test_tests.rs @@ -0,0 +1,480 @@ +use jp_tool::{Action, Context}; +use pretty_assertions::assert_eq; + +use super::{super::error_message, *}; +use crate::util::runner::{ExitCode, MockProcessRunner, ProcessOutput}; + +fn ctx() -> Context { + Context { + root: "/repo".into(), + action: Action::Run, + access: None, + workspace_id: "test".into(), + conversation_id: "test".into(), + } +} + +/// A swift-testing summary line, verbatim from a real run. +/// +/// The leading glyph is the SF Symbol swift-testing prefixes its output with. +const PASSING_SUMMARY: &str = + "\u{1005db} Test run with 30 tests in 6 suites passed after 0.028 seconds."; + +/// What the summary looks like once the tool has cleaned it up. +const CLEANED_SUMMARY: &str = "Test run with 30 tests in 6 suites passed after 0.028 seconds."; + +/// The driver package's own summary, so a test can tell the two runs apart. +const DRIVE_SUMMARY: &str = + "\u{1005db} Test run with 33 tests in 4 suites passed after 0.012 seconds."; + +const CLEANED_DRIVE: &str = "Test run with 33 tests in 4 suites passed after 0.012 seconds."; + +/// A bundle whose tests are all swift-testing always draws this from the +/// `XCTest` runner, whether or not anything ran. +const XCTEST_ZERO: &str = + "Executed 0 tests, with 0 failures (0 unexpected) in 0.000 (0.000) seconds"; + +/// Expect the driver package run and the two app preparation steps, in the +/// order a full run performs them. +/// +/// The package goes first because it is the cheap one, so a failure there is +/// reported before a minute of `xcodebuild`. +fn prepared() -> MockProcessRunner { + MockProcessRunner::builder() + .expect("swift") + .args(&["test", "--package-path", "apps/macos/Tools/jpdrive"]) + .returns_success(DRIVE_SUMMARY) + .expect("just") + .args(&["build-ffi", "debug"]) + .returns_success("") + .expect("xcodegen") + .returns_success("") +} + +/// Expect only the app preparation steps, for a run targeting the app alone. +fn prepared_app_only() -> MockProcessRunner { + MockProcessRunner::builder() + .expect("just") + .args(&["build-ffi", "debug"]) + .returns_success("") + .expect("xcodegen") + .returns_success("") +} + +/// The default run covers the driver package and the app's unit tests, and +/// never the UI bundle: the filter names `JPTests` even with nothing to narrow +/// to, because both bundles are in the one scheme. +#[test] +fn runs_both_targets_by_default() { + let runner = prepared() + .expect("xcodebuild") + .args(&[ + "test", + "-project", + "apps/macos/JP.xcodeproj", + "-scheme", + "JP", + "-destination", + "platform=macOS", + "-only-testing:JPTests", + ]) + .returns_success(PASSING_SUMMARY); + + let result = swift_test_impl(&ctx(), None, None, &runner).unwrap(); + + assert_eq!( + result.unwrap_content(), + format!("```\nDriveKit: {CLEANED_DRIVE}\nJP: {CLEANED_SUMMARY}\n```") + ); +} + +/// The driver package builds and runs without an Xcode project, so targeting it +/// must not drag the app's preparation steps along. +#[test] +fn the_drive_target_skips_the_app_entirely() { + let runner = MockProcessRunner::builder() + .expect("swift") + .args(&["test", "--package-path", "apps/macos/Tools/jpdrive"]) + .returns_success(DRIVE_SUMMARY); + + let result = swift_test_impl(&ctx(), None, Some("drive"), &runner).unwrap(); + + assert_eq!( + result.unwrap_content(), + format!("```\nDriveKit: {CLEANED_DRIVE}\n```") + ); +} + +#[test] +fn the_app_target_runs_only_the_unit_bundle() { + let runner = prepared_app_only() + .expect("xcodebuild") + .args(&[ + "test", + "-project", + "apps/macos/JP.xcodeproj", + "-scheme", + "JP", + "-destination", + "platform=macOS", + "-only-testing:JPTests", + ]) + .returns_success(PASSING_SUMMARY); + + let result = swift_test_impl(&ctx(), None, Some("app"), &runner).unwrap(); + + assert_eq!( + result.unwrap_content(), + format!("```\nJP: {CLEANED_SUMMARY}\n```") + ); +} + +/// The UI tests are not reachable from this tool at all, and asking for them +/// says where they went rather than reporting an unknown target. +#[test] +fn the_ui_target_points_at_the_other_tool() { + // Nothing may be spawned: a run that started building before refusing would + // cost a minute to say no. + let runner = MockProcessRunner::never_called(); + + let message = error_message(swift_test_impl(&ctx(), None, Some("ui"), &runner).unwrap()); + + assert!(message.contains("swift_test_ui"), "got: {message}"); +} + +/// swift-testing writes an issue's message on its own line, under a header +/// naming only the kind of issue. +/// Reporting the header alone turns every recorded issue into "Issue recorded" +/// and drops the sentence saying what went wrong. +#[test] +fn keeps_the_message_under_a_recorded_issue() { + let runner = prepared_app_only() + .expect("xcodebuild") + .returns(ProcessOutput { + stdout: "\u{1005db} Test \"selects a row\" recorded an issue at Foo.swift:12:5: Issue \ + recorded\n\u{21b3} the transcript never appeared. On screen instead: \ + /tmp/uitests/a.png\n" + .to_owned(), + stderr: String::new(), + status: ExitCode::from_code(1), + }); + + let message = error_message(swift_test_impl(&ctx(), None, Some("app"), &runner).unwrap()); + + assert!(message.contains("recorded an issue"), "got: {message}"); + assert!( + message.contains("On screen instead: /tmp/uitests/a.png"), + "got: {message}" + ); +} + +#[test] +fn rejects_an_unknown_target() { + let message = Target::parse(Some("both")).unwrap_err(); + + assert_eq!( + message, + "unknown target 'both', expected one of: app, drive, all" + ); +} + +/// An absent target runs everything, which is what makes the tool useful +/// without the caller knowing the project has two test suites in the first +/// place. +#[test] +fn an_absent_target_means_all() { + assert_eq!(Target::parse(None).unwrap(), Target::All); + assert_eq!(Target::parse(Some("all")).unwrap(), Target::All); + assert_eq!(Target::parse(Some("app")).unwrap(), Target::App); + assert_eq!(Target::parse(Some("drive")).unwrap(), Target::Drive); +} + +/// Each runner takes its own filter syntax, so a name has to be translated for +/// whichever target it is addressed to. +#[test] +fn passes_a_filter_to_each_runner_in_its_own_form() { + let drive = MockProcessRunner::builder() + .expect("swift") + .args(&[ + "test", + "--package-path", + "apps/macos/Tools/jpdrive", + "--filter", + "Tree", + ]) + .returns_success(DRIVE_SUMMARY); + + swift_test_impl(&ctx(), Some("Tree"), Some("drive"), &drive).unwrap(); + + let app = prepared_app_only() + .expect("xcodebuild") + .args(&[ + "test", + "-project", + "apps/macos/JP.xcodeproj", + "-scheme", + "JP", + "-destination", + "platform=macOS", + "-only-testing:JPTests/WorkspaceReaderTests", + ]) + .returns_success(PASSING_SUMMARY); + + swift_test_impl(&ctx(), Some("WorkspaceReaderTests"), Some("app"), &app).unwrap(); +} + +/// A filter naming a suite in the other target matches nothing there, which is +/// the mistake the error has to name rather than passing over. +#[test] +fn a_filter_matching_nothing_in_one_target_fails_the_run() { + let runner = MockProcessRunner::builder() + .expect("swift") + .returns_success("Test run with 0 tests passed after 0.001 seconds."); + + let message = error_message( + swift_test_impl(&ctx(), Some("WorkspaceReaderTests"), Some("drive"), &runner).unwrap(), + ); + + assert!(message.contains("no test ran"), "got: {message}"); + assert!( + message.contains("matches nothing in the bundle"), + "got: {message}" + ); +} + +/// The `XCTest` runner reports zero tests for a bundle that has none of its +/// own, which says nothing about whether the swift-testing tests ran. +/// Treating it as a result would make a mistyped filter look like a pass. +#[test] +fn an_xctest_zero_count_alone_is_not_a_pass() { + let runner = prepared_app_only() + .expect("xcodebuild") + .returns_success(format!("{XCTEST_ZERO}\n** TEST SUCCEEDED **")); + + let message = + error_message(swift_test_impl(&ctx(), Some("NoSuchSuite"), Some("app"), &runner).unwrap()); + + assert!(message.contains("no test ran"), "got: {message}"); +} + +/// A real run prints both, and only the swift-testing line carries a count +/// worth reading. +#[test] +fn drops_the_xctest_zero_count_beside_a_real_summary() { + let runner = prepared_app_only() + .expect("xcodebuild") + .returns_success(format!("{XCTEST_ZERO}\n{PASSING_SUMMARY}")); + + let result = swift_test_impl(&ctx(), None, Some("app"), &runner).unwrap(); + + assert_eq!( + result.unwrap_content(), + format!("```\nJP: {CLEANED_SUMMARY}\n```") + ); +} + +/// A run that reported no summary is a run that did nothing. +/// Reporting it as a pass would make every filtered run worthless. +#[test] +fn a_run_with_no_summary_is_not_a_pass() { + let runner = prepared_app_only() + .expect("xcodebuild") + .returns_success("Testing started\nnote: something unrelated"); + + let message = + error_message(swift_test_impl(&ctx(), Some("NoSuchSuite"), Some("app"), &runner).unwrap()); + + assert!(message.contains("no test ran"), "got: {message}"); +} + +/// `XCTest` words its summary differently from swift-testing, and a bundle can +/// hold both. +#[test] +fn recognizes_an_xctest_summary() { + let runner = prepared_app_only().expect("xcodebuild").returns_success( + "Test Suite 'All tests' passed\n\t Executed 20 tests, with 0 failures in 1.234 seconds", + ); + + let result = swift_test_impl(&ctx(), None, Some("app"), &runner).unwrap(); + + assert!( + result.unwrap_content().contains("Executed 20 tests"), + "expected the XCTest summary to be reported" + ); +} + +/// The raw log is thousands of lines of compiler invocations; only the lines +/// that say what ran are worth relaying. +#[test] +fn drops_build_noise_from_the_summary() { + let runner = prepared_app_only() + .expect("xcodebuild") + .returns_success(format!( + "CompileSwift normal arm64 WorkspaceReader.swift\nLd \ + JP.app/Contents/MacOS/JP\n{PASSING_SUMMARY}" + )); + + let result = swift_test_impl(&ctx(), None, Some("app"), &runner).unwrap(); + + assert_eq!( + result.unwrap_content(), + format!("```\nJP: {CLEANED_SUMMARY}\n```") + ); +} + +#[test] +fn reports_failing_tests() { + let runner = prepared_app_only() + .expect("xcodebuild") + .returns(ProcessOutput { + stdout: "WorkspaceReaderTests.swift:61: error: Expectation failed".to_owned(), + stderr: String::new(), + status: ExitCode::from_code(65), + }); + + let message = error_message(swift_test_impl(&ctx(), None, Some("app"), &runner).unwrap()); + + assert_eq!( + message, + "JP tests failed:\n\n```\nWorkspaceReaderTests.swift:61: error: Expectation failed\n```" + ); +} + +/// A failing package run is labelled as such, so it is clear which suite broke +/// without reading the diagnostics. +#[test] +fn names_the_target_that_failed() { + let runner = MockProcessRunner::builder() + .expect("swift") + .returns(ProcessOutput { + stdout: "ActTests.swift:32: error: Expectation failed".to_owned(), + stderr: String::new(), + status: ExitCode::from_code(1), + }); + + let message = error_message(swift_test_impl(&ctx(), None, Some("drive"), &runner).unwrap()); + + assert!( + message.starts_with("DriveKit tests failed:"), + "got: {message}" + ); +} + +/// A crashed run reports no failing test and no summary. +/// Naming the crash beats the alternative, which was dumping the whole build +/// log and leaving the reader to find the end of it. +#[test] +fn names_a_crash_rather_than_dumping_the_log() { + let runner = MockProcessRunner::builder() + .expect("swift") + .returns(ProcessOutput { + stdout: "Building for debugging...\nRestarting after unexpected exit, crash, or test \ + timeout in ScrollMemoryTests.roundTrips()" + .to_owned(), + stderr: String::new(), + status: ExitCode::from_code(1), + }); + + let message = error_message(swift_test_impl(&ctx(), None, Some("drive"), &runner).unwrap()); + + assert_eq!( + message, + "DriveKit tests failed:\n\n```\nRestarting after unexpected exit, crash, or test timeout \ + in ScrollMemoryTests.roundTrips()\n```" + ); +} + +/// A run that died with nothing recognizable in it still has to say something, +/// and the useful end of a build log is the last of it, not the first. +#[test] +fn shows_the_end_of_an_unrecognizable_failure() { + let noise = (0..100) + .map(|index| format!("CompileSwift normal arm64 File{index}.swift")) + .collect::>() + .join("\n"); + + let runner = MockProcessRunner::builder() + .expect("swift") + .returns(ProcessOutput { + stdout: format!("{noise}\nthe last thing it printed"), + stderr: String::new(), + status: ExitCode::from_code(1), + }); + + let message = error_message(swift_test_impl(&ctx(), None, Some("drive"), &runner).unwrap()); + + assert!( + message.contains("the run died rather than failing"), + "got: {message}" + ); + assert!( + message.contains("the last thing it printed"), + "got: {message}" + ); + assert!( + !message.contains("File0.swift"), + "expected the end of the log, not the start of it" + ); +} + +/// A failing run names the test that failed and then how the run ended, and +/// both are wanted: the first says what to fix, the second says how much broke. +#[test] +fn reports_the_failing_test_and_the_summary() { + let runner = prepared_app_only() + .expect("xcodebuild") + .returns(ProcessOutput { + stdout: "CompileSwift normal arm64\n\u{1005df} Test \"orders an empty list\" recorded \ + an issue at TimestampTests.swift:88\n\u{1005db} Test run with 30 tests in 6 \ + suites failed after 0.03 seconds." + .to_owned(), + stderr: String::new(), + status: ExitCode::from_code(65), + }); + + let message = error_message(swift_test_impl(&ctx(), None, Some("app"), &runner).unwrap()); + + assert_eq!( + message, + "JP tests failed:\n\n```\nTest \"orders an empty list\" recorded an issue at \ + TimestampTests.swift:88\nTest run with 30 tests in 6 suites failed after 0.03 \ + seconds.\n```" + ); +} + +/// A failure in the cheap suite stops the run before the expensive one, so the +/// diagnostics arrive in seconds rather than after a full app build. +#[test] +fn a_package_failure_stops_before_the_app_build() { + let runner = MockProcessRunner::builder() + .expect("swift") + .returns(ProcessOutput { + stdout: String::new(), + stderr: "error: no such module 'Testing'".to_owned(), + status: ExitCode::from_code(1), + }); + + let message = error_message(swift_test_impl(&ctx(), None, None, &runner).unwrap()); + + assert!( + message.starts_with("DriveKit tests failed:"), + "got: {message}" + ); +} + +/// The bridging header has to exist before `xcodebuild` plans the build, so a +/// failed library build stops the run rather than producing a confusing "file +/// not found" from the header scan. +#[test] +fn stops_when_the_library_fails_to_build() { + let runner = MockProcessRunner::builder() + .expect("just") + .returns_error("error: could not compile `jp_ffi`"); + + let message = error_message(swift_test_impl(&ctx(), None, Some("app"), &runner).unwrap()); + + assert!( + message.starts_with("Building `jp_ffi` failed:"), + "got: {message}" + ); +} diff --git a/.config/jp/tools/src/swift/test_ui.rs b/.config/jp/tools/src/swift/test_ui.rs new file mode 100644 index 000000000..19700714c --- /dev/null +++ b/.config/jp/tools/src/swift/test_ui.rs @@ -0,0 +1,242 @@ +//! `swift_test_ui` — run named UI tests against the macOS app. +//! +//! Separate from [`swift_test`] because a UI test is a different kind of thing +//! to run. +//! It launches the app, takes the screen for as long as it drives it, and costs +//! seconds rather than milliseconds. +//! Running the suite is a job for CI; running two tests you just wrote is a job +//! for this. +//! +//! Which is why the tests to run are required rather than optional. +//! There is no spelling of this tool that means "run all of them" — reaching +//! for that is how a red-green loop turns into a minute per iteration. +//! +//! [`swift_test`]: super::test + +use jp_tool::Context; + +use super::{ + PROJECT_PATH, SCHEME, prepare, + report::{ + RESULT_BUNDLE, UI_BUNDLE, clear_result_bundle, clear_screenshots, close_leftover_apps, + collect_bundle_issues, collect_failures, collect_screenshots, collect_staged_issues, + outcome, ui_bundle_filter, + }, +}; +use crate::util::{ + ToolResult, error, + runner::{DuctProcessRunner, ProcessOutput, ProcessRunner, Stopped}, +}; + +/// The line `xcodebuild` prints when a swift-testing expectation fails. +/// +/// Watched for rather than waited on: the run is stopped the moment one +/// appears, so a broken app costs one test's worth of time instead of the whole +/// suite's. +const FAILURE_MARKER: &str = "recorded an issue"; + +/// Whether this process is running under continuous integration. +/// +/// CI wants every result from one run, because nobody is sitting there to run +/// it again; a person at a keyboard wants the first failure as fast as +/// possible. +/// Same suite, opposite priorities, so the environment decides. +fn under_ci() -> bool { + std::env::var("CI").is_ok_and(|value| !value.is_empty()) +} + +pub(crate) async fn swift_test_ui(ctx: &Context, tests: Option>) -> ToolResult { + swift_test_ui_impl(ctx, tests.as_deref(), &DuctProcessRunner) +} + +fn swift_test_ui_impl( + ctx: &Context, + tests: Option<&[String]>, + runner: &R, +) -> ToolResult { + let Some(tests) = tests.filter(|tests| !tests.is_empty()) else { + let mut message = "`tests` is required: name the suites or tests to run. Every one of \ + these launches the app and drives it through the screen, so there is \ + deliberately no way to ask for all of them; that is CI's job, through \ + `just test-app-ui`." + .to_owned(); + + // Asked of the bundle rather than read off a list someone maintains: + // a list in a document rots, and the one place that cannot is the + // bundle itself. + match enumerate(ctx, runner) { + Ok(names) if !names.is_empty() => { + message.push_str("\n\nWhat there is to run:\n\n```\n"); + message.push_str(&names.join("\n")); + message.push_str("\n```\n"); + } + _ => message.push_str( + "\n\nA name is a whole type path with a swift-testing function's trailing `()`, \ + such as `UISuite/ConversationListTests/clickSelects()`.", + ), + } + + return error(message); + }; + + if let Some(failure) = prepare(ctx, "debug", runner)? { + return error(failure); + } + + // Emptied before the run so what is collected afterwards belongs to it and + // not to the run before. + clear_screenshots(); + clear_result_bundle(&ctx.root); + + let (output, stopped) = run(ctx, tests, runner)?; + + // What the runner itself printed, in the order the sources can be trusted. + // + // A sealed bundle is the richest, and only a run that finished has one. The + // staged runner output covers the rest — every run stopped at its first + // failure — and holds the same expectations and comments as plain text. The + // log the tests keep themselves is last, and needed only for a failure + // recorded before the runner wrote anything. + let reported = collect_bundle_issues(ctx, runner) + .or_else(|| collect_staged_issues(&ctx.root)) + .unwrap_or_else(collect_failures); + let detail = reported + &collect_screenshots(&ctx.root); + + if stopped.is_yes() { + close_leftover_apps(); + + return error(format!( + "Stopped the run at the first failure, so the tests after it did not run. Set `CI=1` \ + to let a run finish and report everything.{detail}" + )); + } + + match outcome(&output, "JP") { + Ok(summary) => Ok(format!("```\n{summary}\n```").into()), + Err(message) => error(message + &detail), + } +} + +/// Every test in the UI bundle, as `xcodebuild` reports them. +/// +/// Costs a build, which is why it is only reached when a caller named nothing +/// and the run is not going to happen anyway. +/// +/// The JSON is walked for `identifier` keys rather than deserialized into the +/// document's shape: this is an error path, the shape has changed between Xcode +/// releases, and a parse failure here should cost the caller a less helpful +/// message rather than a second failure on top of the first. +fn enumerate(ctx: &Context, runner: &R) -> Result, std::io::Error> { + let output = runner.run( + "xcodebuild", + &[ + "test", + "-project", + PROJECT_PATH, + "-scheme", + SCHEME, + "-destination", + "platform=macOS", + "-enumerate-tests", + "-test-enumeration-style", + "flat", + "-test-enumeration-format", + "json", + ], + &ctx.root, + )?; + + let Some(start) = output.stdout.find('{') else { + return Ok(Vec::new()); + }; + + let Ok(json) = serde_json::from_str::(&output.stdout[start..]) else { + return Ok(Vec::new()); + }; + + let mut identifiers = Vec::new(); + collect_identifiers(&json, &mut identifiers); + + // `-enumerate-tests` reports the whole scheme whatever `-only-testing` + // says, so the unit bundle is in there too. The prefix comes off because + // this tool puts it back on. + let prefix = format!("{UI_BUNDLE}/"); + let mut names: Vec = identifiers + .iter() + .filter_map(|id| id.strip_prefix(&prefix)) + .map(str::to_owned) + .collect(); + + names.sort(); + names.dedup(); + + Ok(names) +} + +/// Gather every `identifier` string anywhere in `value`. +fn collect_identifiers(value: &serde_json::Value, into: &mut Vec) { + match value { + serde_json::Value::Object(map) => { + for (key, child) in map { + if key == "identifier" + && let Some(name) = child.as_str() + { + into.push(name.to_owned()); + } + collect_identifiers(child, into); + } + } + serde_json::Value::Array(items) => { + for item in items { + collect_identifiers(item, into); + } + } + _ => {} + } +} + +/// Run the named tests, stopping at the first failure unless under CI. +/// +/// One `-only-testing` argument each, which `xcodebuild` unions. +/// A name matching nothing fails the whole run rather than being passed over, +/// so a typo is reported instead of quietly narrowing the run to the rest. +/// +/// Stopped from out here because nothing inside can do it. swift-testing has no +/// cancellation, and a test that exits its own process makes `xcodebuild` +/// relaunch the runner, finish the remaining tests, and report the whole thing +/// as passing. +fn run( + ctx: &Context, + tests: &[String], + runner: &R, +) -> Result<(ProcessOutput, Stopped), std::io::Error> { + let filters: Vec = tests.iter().map(|test| ui_bundle_filter(test)).collect(); + + let mut args = vec![ + "test", + "-project", + PROJECT_PATH, + "-scheme", + SCHEME, + "-destination", + "platform=macOS", + // Written where the report can read it back. Without this the bundle + // lands in derived data under a timestamped name, which would have to be + // scraped out of the log. + "-resultBundlePath", + RESULT_BUNDLE, + ]; + args.extend(filters.iter().map(String::as_str)); + + if under_ci() { + return Ok((runner.run("xcodebuild", &args, &ctx.root)?, Stopped::No)); + } + + runner.run_until("xcodebuild", &args, &ctx.root, &|line| { + line.contains(FAILURE_MARKER) + }) +} + +#[cfg(test)] +#[path = "test_ui_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/swift/test_ui_tests.rs b/.config/jp/tools/src/swift/test_ui_tests.rs new file mode 100644 index 000000000..0a32a6707 --- /dev/null +++ b/.config/jp/tools/src/swift/test_ui_tests.rs @@ -0,0 +1,172 @@ +use jp_tool::{Action, Context}; +use pretty_assertions::assert_eq; + +use super::{super::error_message, *}; +use crate::util::runner::{ExitCode, MockProcessRunner, ProcessOutput}; + +fn ctx() -> Context { + Context { + root: "/repo".into(), + action: Action::Run, + access: None, + workspace_id: "test".into(), + conversation_id: "test".into(), + } +} + +/// A swift-testing summary line, verbatim from a real run. +const PASSING_SUMMARY: &str = + "\u{1005db} Test run with 2 tests in 2 suites passed after 11.2 seconds."; + +const CLEANED_SUMMARY: &str = "Test run with 2 tests in 2 suites passed after 11.2 seconds."; + +/// Expect the two preparation steps every Xcode run performs first. +fn prepared() -> MockProcessRunner { + MockProcessRunner::builder() + .expect("just") + .args(&["build-ffi", "debug"]) + .returns_success("") + .expect("xcodegen") + .returns_success("") +} + +#[test] +fn runs_each_named_test() { + let runner = prepared() + .expect("xcodebuild") + .args(&[ + "test", + "-project", + "apps/macos/JP.xcodeproj", + "-scheme", + "JP", + "-destination", + "platform=macOS", + "-resultBundlePath", + "tmp/uitests/run.xcresult", + "-only-testing:JPUITests/UISuite/ConversationListTests/clickSelects()", + "-only-testing:JPUITests/UISuite/ConversationListTests/labelsRows()", + ]) + .returns_success(PASSING_SUMMARY); + + let tests = [ + "UISuite/ConversationListTests/clickSelects()".to_owned(), + "UISuite/ConversationListTests/labelsRows()".to_owned(), + ]; + let result = swift_test_ui_impl(&ctx(), Some(&tests), &runner).unwrap(); + + assert_eq!( + result.unwrap_content(), + format!("```\nJP: {CLEANED_SUMMARY}\n```") + ); +} + +/// The whole point of the tool being separate: there is no way to ask it for +/// every UI test, because every one of them launches the app. +#[test] +fn refuses_to_run_without_names() { + let runner = MockProcessRunner::builder() + .expect("xcodebuild") + .returns_success( + r#"{"values":[{"enabledTests":[ + {"identifier":"JPUITests/UISuite/ConversationListTests/labelsRows()"}, + {"identifier":"JPTests/ConversationRefTests/exportsAsAURI()"} + ]}]}"#, + ); + + let message = error_message(swift_test_ui_impl(&ctx(), None, &runner).unwrap()); + + assert!(message.contains("`tests` is required"), "got: {message}"); + assert!(message.contains("just test-app-ui"), "got: {message}"); + + // Asked of the bundle, so the list cannot drift from what is really there. + // Named as this tool takes them: no bundle prefix, because it adds one. + assert!( + message.contains("\nUISuite/ConversationListTests/labelsRows()"), + "got: {message}" + ); + + // `-enumerate-tests` reports the whole scheme, and the unit tests are not + // this tool's to run. + assert!(!message.contains("ConversationRefTests"), "got: {message}"); +} + +/// The list is a nicety on a path that has already failed. +/// An `xcodebuild` too old to enumerate, or a shape this cannot read, costs the +/// caller the naming convention instead of a second failure. +#[test] +fn refuses_helpfully_when_enumeration_fails() { + let runner = MockProcessRunner::builder() + .expect("xcodebuild") + .returns_error("unknown option -enumerate-tests"); + + let message = error_message(swift_test_ui_impl(&ctx(), None, &runner).unwrap()); + + assert!(message.contains("`tests` is required"), "got: {message}"); + assert!(message.contains("trailing `()`"), "got: {message}"); +} + +/// An empty list is the same request as no list, and gets the same answer +/// rather than an `xcodebuild` run with no filter — which would run the whole +/// bundle, the one outcome this tool exists to prevent. +#[test] +fn refuses_to_run_with_an_empty_list() { + let runner = MockProcessRunner::builder() + .expect("xcodebuild") + .returns_success(""); + + let message = error_message(swift_test_ui_impl(&ctx(), Some(&[]), &runner).unwrap()); + + assert!(message.contains("`tests` is required"), "got: {message}"); +} + +/// A run is stopped from outside, so the marker that stops it has to be a line +/// `xcodebuild` really prints. +/// This is one, verbatim. +#[test] +fn recognizes_the_line_that_stops_a_run() { + let line = + "\u{1005db} Test \"selects a row\" recorded an issue at Foo.swift:12:5: Issue recorded"; + + assert!(line.contains(FAILURE_MARKER)); +} + +/// CI has nobody waiting on it and wants every result from the one run it gets, +/// so it opts out of stopping. +#[test] +fn ci_is_read_from_the_environment() { + // SAFETY: this test reads back only what it just wrote, and the tools crate + // runs its tests in one process where nothing else touches `CI`. + unsafe { + std::env::set_var("CI", "1"); + } + assert!(under_ci()); + + unsafe { + std::env::set_var("CI", ""); + } + assert!(!under_ci(), "an empty value is not being under CI"); + + unsafe { + std::env::remove_var("CI"); + } + assert!(!under_ci()); +} + +/// A failing run names the screenshots the tests left behind, so what was on +/// screen can be looked at rather than guessed. +#[test] +fn reports_a_failure_with_its_summary() { + let runner = prepared().expect("xcodebuild").returns(ProcessOutput { + stdout: "\u{1005db} Test \"selects a row\" recorded an issue at Foo.swift:12:5: Issue \ + recorded\n" + .to_owned(), + stderr: String::new(), + status: ExitCode::from_code(1), + }); + + let tests = ["UISuite/ConversationListTests/clickSelects()".to_owned()]; + let message = error_message(swift_test_ui_impl(&ctx(), Some(&tests), &runner).unwrap()); + + assert!(message.contains("recorded an issue"), "got: {message}"); +} diff --git a/.config/jp/tools/src/swift_tests.rs b/.config/jp/tools/src/swift_tests.rs new file mode 100644 index 000000000..b018d96a6 --- /dev/null +++ b/.config/jp/tools/src/swift_tests.rs @@ -0,0 +1,91 @@ +use jp_tool::{Action, Context}; +use pretty_assertions::assert_eq; + +use super::*; +use crate::util::runner::{ExitCode, MockProcessRunner, ProcessOutput}; + +fn ctx() -> Context { + Context { + root: "/repo".into(), + action: Action::Run, + access: None, + workspace_id: "test".into(), + conversation_id: "test".into(), + } +} + +/// Both generated inputs are built, in the order the Xcode build needs them: +/// the header must exist before the project is asked to scan it. +#[test] +fn prepare_builds_the_library_then_the_project() { + let runner = MockProcessRunner::builder() + .expect("just") + .args(&["build-ffi", "debug"]) + .returns_success("") + .expect("xcodegen") + .args(&[ + "generate", + "--spec", + "apps/macos/project.yml", + "--project", + "apps/macos", + ]) + .returns_success(""); + + assert_eq!(prepare(&ctx(), "debug", &runner).unwrap(), None); +} + +/// A failed library build short-circuits: the mock would panic on drop if +/// xcodegen had been expected and never run, which is what proves the ordering. +#[test] +fn prepare_stops_at_the_first_failure() { + let runner = MockProcessRunner::builder() + .expect("just") + .returns_error("boom"); + + let failure = prepare(&ctx(), "debug", &runner).unwrap(); + + assert_eq!( + failure, + Some("Building `jp_ffi` failed:\n\n```\nboom\n```".to_owned()) + ); +} + +#[test] +fn report_prefers_stdout() { + let output = ProcessOutput { + stdout: "from stdout".to_owned(), + stderr: "from stderr".to_owned(), + status: ExitCode::from_code(1), + }; + + assert_eq!(report(&output, "tool"), "from stdout"); +} + +/// Most tools report on stderr; only `xcodebuild` puts diagnostics on stdout. +#[test] +fn report_falls_back_to_stderr() { + let output = ProcessOutput { + stdout: String::new(), + stderr: "from stderr".to_owned(), + status: ExitCode::from_code(1), + }; + + assert_eq!(report(&output, "tool"), "from stderr"); +} + +/// Silence plus a non-zero status is still worth reporting, so the caller is +/// not left with an empty code block. +#[test] +fn report_names_the_program_when_it_said_nothing() { + let output = ProcessOutput { + stdout: String::new(), + stderr: String::new(), + status: ExitCode::from_code(70), + }; + + assert_eq!( + report(&output, "xcodebuild"), + "xcodebuild exited with status 70 and no diagnostics." + ); +} diff --git a/.config/jp/tools/src/util.rs b/.config/jp/tools/src/util.rs index 13d7e0d30..7d5ceb345 100644 --- a/.config/jp/tools/src/util.rs +++ b/.config/jp/tools/src/util.rs @@ -1,5 +1,7 @@ pub mod diff; +pub mod paths; pub mod runner; +pub mod trace; pub mod xml; use jp_tool::Outcome; diff --git a/.config/jp/tools/src/util/paths.rs b/.config/jp/tools/src/util/paths.rs new file mode 100644 index 000000000..ad888807a --- /dev/null +++ b/.config/jp/tools/src/util/paths.rs @@ -0,0 +1,219 @@ +//! Naming the root of an absolute path instead of printing it. +//! +//! Reports made by these tools get pasted into issues, and they are full of +//! paths from elsewhere: DWARF holds the source of every symbolicated frame as +//! an absolute path on the machine that did the build, dhat does the same, and +//! anything quoted from a subprocess's stderr names whatever that subprocess +//! was looking at. +//! None of it helps a reader, and all of it is somebody's filesystem layout. +//! +//! Replacing the prefix with the variable that names it keeps the path +//! actionable rather than merely censored: `$CARGO_HOME/registry/…` says +//! exactly where to look without saying whose machine it is. +//! A path inside the repository needs no variable at all — relative is both +//! shorter and what these reports have always printed. +//! +//! Two entry points, for the two shapes this comes in. +//! [`shorten`] takes a value already known to be a path. +//! [`shorten_within`] takes prose with paths somewhere inside it, which is what +//! a quoted stderr or a rendered stack frame is. + +use camino::{Utf8Path, Utf8PathBuf}; + +/// A prefix worth hiding, and what to show in its place. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Shortening { + /// Absolute prefix, with any trailing separator removed. + prefix: Utf8PathBuf, + + /// What replaces it. + /// + /// Empty shows the remainder on its own, which is what a path inside the + /// repository wants. + label: String, +} + +impl Shortening { + /// A shortening for `prefix`, or `None` when there is nothing usable to + /// match on. + /// + /// A prefix that trims to nothing would match every absolute path and + /// rewrite the lot, so an unset or root-valued variable is dropped rather + /// than applied. + fn new(prefix: &str, label: &str) -> Option { + let trimmed = prefix.trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + + Some(Self { + prefix: Utf8PathBuf::from(trimmed), + label: label.to_owned(), + }) + } +} + +/// The prefixes worth hiding, read from the environment. +pub fn shortenings(root: &Utf8Path) -> Vec { + let home = std::env::var("HOME").ok(); + let cargo = std::env::var("CARGO_HOME").ok(); + let rustup = std::env::var("RUSTUP_HOME").ok(); + + shortenings_from(root, home.as_deref(), cargo.as_deref(), rustup.as_deref()) +} + +/// The prefixes worth hiding, given where things live. +/// +/// `CARGO_HOME` and `RUSTUP_HOME` fall back to their documented defaults under +/// the home directory, and are labelled by variable name either way: the name +/// is what tells a reader where to look, whether or not the variable happens to +/// be set on the machine that produced the report. +pub fn shortenings_from( + root: &Utf8Path, + home: Option<&str>, + cargo: Option<&str>, + rustup: Option<&str>, +) -> Vec { + let under_home = |explicit: Option<&str>, default: &str| -> Option { + explicit + .map(Utf8PathBuf::from) + .or_else(|| home.map(|home| Utf8Path::new(home).join(default))) + }; + + let mut out: Vec = Vec::new(); + out.extend(Shortening::new(root.as_str(), "")); + + if let Some(path) = under_home(cargo, ".cargo") { + out.extend(Shortening::new(path.as_str(), "$CARGO_HOME")); + } + if let Some(path) = under_home(rustup, ".rustup") { + out.extend(Shortening::new(path.as_str(), "$RUSTUP_HOME")); + } + if let Some(home) = home { + out.extend(Shortening::new(home, "$HOME")); + } + + // Longest first, so a registry path under the home directory is reported as + // living under `$CARGO_HOME` rather than under `$HOME`. + out.sort_by_key(|shortening| std::cmp::Reverse(shortening.prefix.as_str().len())); + out +} + +/// `path` with the first matching prefix replaced by the name for it. +/// +/// A path under none of them is returned unchanged. +/// That covers what rustc already remapped (`/rustc//…`), which names no +/// machine, and the SDK paths under `/Applications`, which name nobody. +pub fn shorten(path: &str, shortenings: &[Shortening]) -> String { + for shortening in shortenings { + let Some(rest) = strip(path, shortening.prefix.as_str()) else { + continue; + }; + + if shortening.label.is_empty() { + return if rest.is_empty() { + ".".to_owned() + } else { + rest.to_owned() + }; + } + + return if rest.is_empty() { + shortening.label.clone() + } else { + format!("{}/{rest}", shortening.label) + }; + } + + path.to_owned() +} + +/// Every path inside `text`, with its prefix replaced by the name for it. +/// +/// For a report rather than a path: a quoted stderr line, a dhat frame carrying +/// a source location, a trace event's fields. +/// Applied to a whole rendered report it catches every path in it at once, +/// which is the point — there is no enumerating the places a subprocess might +/// name a file. +pub fn shorten_within(text: &str, shortenings: &[Shortening]) -> String { + let mut out = text.to_owned(); + + for shortening in shortenings { + let label = if shortening.label.is_empty() { + // A repository path becomes relative, and mid-prose that means the + // separator after it has to go too. + String::new() + } else { + shortening.label.clone() + }; + + out = replace_at_boundaries(&out, shortening.prefix.as_str(), &label); + } + + out +} + +/// Replace every occurrence of `prefix` in `text` that ends on a component +/// boundary. +/// +/// The boundary test is what keeps `/Users/jean` from rewriting the front of +/// `/Users/jeanne`. +/// A path continues into `/`, and stops at anything that cannot be part of a +/// name. +fn replace_at_boundaries(text: &str, prefix: &str, label: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + + while let Some(at) = rest.find(prefix) { + let after = &rest[at + prefix.len()..]; + let boundary = after + .chars() + .next() + .is_none_or(|c| !(c.is_alphanumeric() || matches!(c, '-' | '_' | '.'))); + + out.push_str(&rest[..at]); + if !boundary { + out.push_str(prefix); + rest = after; + continue; + } + + // A repository path has no label, so the `/` that followed the prefix + // has to go with it or the remainder still reads as absolute. The root + // on its own becomes `.`, because dropping it entirely would leave a + // field with no value at all. + if label.is_empty() { + if let Some(tail) = after.strip_prefix('/') { + rest = tail; + } else { + out.push('.'); + rest = after; + } + continue; + } + + out.push_str(label); + rest = after; + } + + out.push_str(rest); + out +} + +/// What follows `prefix` in `path`, when `prefix` covers a whole leading run of +/// components. +/// +/// The boundary check is the point: a plain string prefix would rewrite +/// `/Users/jeanne/src` against a home of `/Users/jean`. +fn strip<'a>(path: &'a str, prefix: &str) -> Option<&'a str> { + let rest = path.strip_prefix(prefix)?; + if rest.is_empty() { + return Some(rest); + } + + rest.strip_prefix('/') +} + +#[cfg(test)] +#[path = "paths_tests.rs"] +mod tests; diff --git a/.config/jp/tools/src/util/paths_tests.rs b/.config/jp/tools/src/util/paths_tests.rs new file mode 100644 index 000000000..7d9167e3a --- /dev/null +++ b/.config/jp/tools/src/util/paths_tests.rs @@ -0,0 +1,166 @@ +use camino::Utf8Path; + +use super::{shorten, shortenings_from}; + +/// The layout of a real machine, as the failing reports showed it. +fn fixture() -> Vec { + shortenings_from( + Utf8Path::new("/Users/jean/Projects/jp"), + Some("/Users/jean"), + None, + None, + ) +} + +/// The two shapes that leaked into a real summary. +#[test] +fn names_the_variable_a_dependency_lives_under() { + let shortenings = fixture(); + + assert_eq!( + shorten( + "/Users/jean/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/hashbrown-0.16.1/\ + src/raw/mod.rs", + &shortenings + ), + "$CARGO_HOME/registry/src/index.crates.io-1949cf8c6b5b557f/hashbrown-0.16.1/src/raw/mod.rs" + ); + + assert_eq!( + shorten( + "/Users/jean/.rustup/toolchains/nightly-2026-03-26-aarch64-apple-darwin/lib/rustlib/\ + src/rust/library/core/src/ub_checks.rs", + &shortenings + ), + "$RUSTUP_HOME/toolchains/nightly-2026-03-26-aarch64-apple-darwin/lib/rustlib/src/rust/\ + library/core/src/ub_checks.rs" + ); +} + +/// A file in the repository needs no variable: relative is shorter and is what +/// every other report here prints. +#[test] +fn a_path_in_the_repository_becomes_relative() { + assert_eq!( + shorten( + "/Users/jean/Projects/jp/crates/jp_config/src/conversation/tool/style.rs", + &fixture() + ), + "crates/jp_config/src/conversation/tool/style.rs" + ); +} + +/// Anything else under the home directory still must not name it. +#[test] +fn any_other_path_under_home_names_home() { + assert_eq!( + shorten("/Users/jean/scratch/notes.rs", &fixture()), + "$HOME/scratch/notes.rs" + ); +} + +/// Ordering, not luck: the registry sits under the home directory, so a +/// shortest-first pass would report every dependency as living under `$HOME`. +#[test] +fn the_most_specific_prefix_wins() { + let shortenings = fixture(); + let shortened = shorten("/Users/jean/.cargo/registry/src/x.rs", &shortenings); + + assert!( + shortened.starts_with("$CARGO_HOME/"), + "unexpected shortening: {shortened}" + ); +} + +/// The explicit variables take precedence over the defaults under home, because +/// a machine that sets them does not keep them there. +#[test] +fn an_explicit_variable_is_used_over_the_default() { + let shortenings = shortenings_from( + Utf8Path::new("/repo"), + Some("/Users/jean"), + Some("/opt/cargo"), + Some("/opt/rustup"), + ); + + assert_eq!( + shorten("/opt/cargo/registry/src/x.rs", &shortenings), + "$CARGO_HOME/registry/src/x.rs" + ); + assert_eq!( + shorten("/opt/rustup/toolchains/x/lib.rs", &shortenings), + "$RUSTUP_HOME/toolchains/x/lib.rs" + ); + + // The default location is no longer special, so it reads as what it is. + assert_eq!( + shorten("/Users/jean/.cargo/registry/src/x.rs", &shortenings), + "$HOME/.cargo/registry/src/x.rs" + ); +} + +/// A plain string prefix would rewrite this against a home of `/Users/jean`, +/// which is the kind of bug that only shows up on somebody else's machine. +#[test] +fn a_prefix_only_matches_whole_components() { + let shortenings = fixture(); + + assert_eq!( + shorten("/Users/jeanne/src/main.rs", &shortenings), + "/Users/jeanne/src/main.rs" + ); + assert_eq!( + shorten("/Users/jean/Projects/jp-other/src/main.rs", &shortenings), + "$HOME/Projects/jp-other/src/main.rs" + ); +} + +/// What rustc already remapped names no machine, and neither do the SDKs. +#[test] +fn a_path_under_nothing_known_is_left_alone() { + let shortenings = fixture(); + + assert_eq!( + shorten( + "/rustc/80d0e4be6f15899649ba31669077c59a986f96cc/library/core/src/str/validations.rs", + &shortenings + ), + "/rustc/80d0e4be6f15899649ba31669077c59a986f96cc/library/core/src/str/validations.rs" + ); + assert_eq!( + shorten("/Applications/Xcode.app/Contents/Developer/x", &shortenings), + "/Applications/Xcode.app/Contents/Developer/x" + ); +} + +/// An unset home leaves the dependency prefixes with nothing to fall back to, +/// and the repository still works. +#[test] +fn nothing_to_go_on_leaves_paths_alone() { + let shortenings = shortenings_from(Utf8Path::new("/repo"), None, None, None); + + assert_eq!(shorten("/repo/src/main.rs", &shortenings), "src/main.rs"); + assert_eq!( + shorten("/Users/jean/.cargo/x.rs", &shortenings), + "/Users/jean/.cargo/x.rs" + ); +} + +/// A home of `/` would otherwise match every absolute path and rewrite the lot. +#[test] +fn a_root_valued_variable_is_dropped_rather_than_applied() { + let shortenings = shortenings_from(Utf8Path::new("/repo"), Some("/"), None, None); + + assert_eq!( + shorten("/etc/passwd", &shortenings), + "/etc/passwd", + "a root home must not rewrite unrelated paths" + ); +} + +/// The path that is exactly the root has no remainder to show. +#[test] +fn the_root_itself_shortens_to_a_dot() { + assert_eq!(shorten("/Users/jean/Projects/jp", &fixture()), "."); + assert_eq!(shorten("/Users/jean", &fixture()), "$HOME"); +} diff --git a/.config/jp/tools/src/util/runner.rs b/.config/jp/tools/src/util/runner.rs index a373bf6b9..96641c4ab 100644 --- a/.config/jp/tools/src/util/runner.rs +++ b/.config/jp/tools/src/util/runner.rs @@ -1,7 +1,46 @@ //! Generic process runner abstraction for dependency injection in tests. +use std::{ + io::{BufRead as _, BufReader}, + process::Command, + thread, + time::{Duration, Instant}, +}; + use camino::Utf8Path; -use duct::cmd; +use duct::{ReaderHandle, cmd}; + +/// How long an interrupted process gets to clean up before it is killed. +const INTERRUPT_GRACE: Duration = Duration::from_secs(5); + +/// How often to check whether it has finished unwinding. +const INTERRUPT_POLL: Duration = Duration::from_millis(50); + +/// Stop `handle` the way Ctrl-C would, and kill it if that is not enough. +/// +/// `SIGINT` rather than an outright kill because a process that spawned others +/// is the only one that knows how to stop them. +/// `xcodebuild` interrupted tears down its test session, which is what stops +/// the app a UI test was driving; killed outright it leaves that app running on +/// the screen. +fn interrupt(handle: &ReaderHandle) { + for pid in handle.pids() { + let _sent = Command::new("kill") + .args(["-INT", &pid.to_string()]) + .status(); + } + + let deadline = Instant::now() + INTERRUPT_GRACE; + while Instant::now() < deadline { + if matches!(handle.try_wait(), Ok(Some(_))) { + return; + } + + thread::sleep(INTERRUPT_POLL); + } + + let _killed = handle.kill(); +} /// The exit code of a process. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] @@ -146,6 +185,42 @@ pub trait ProcessRunner { working_dir: &Utf8Path, opts: &RunnerOpts<'_>, ) -> Result; + + /// Run `program`, killing it as soon as a line of its output satisfies + /// `stop`. + /// + /// Both streams are merged, because a caller watching for something has no + /// way to interleave two captures after the fact. + /// + /// The default runs to completion and reports that it stopped nothing, so a + /// runner that cannot stream still answers correctly — only later than a + /// caller would like. + fn run_until( + &self, + program: &str, + args: &[&str], + working_dir: &Utf8Path, + _stop: &dyn Fn(&str) -> bool, + ) -> Result<(ProcessOutput, Stopped), std::io::Error> { + let output = self.run(program, args, working_dir)?; + + Ok((output, Stopped::No)) + } +} + +/// Whether a run was cut short or reached its own end. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stopped { + /// The process was killed because a line matched. + Yes, + /// The process ended on its own. + No, +} + +impl Stopped { + pub const fn is_yes(self) -> bool { + matches!(self, Self::Yes) + } } /// Production implementation that uses duct to run actual external processes. @@ -222,6 +297,59 @@ impl ProcessRunner for DuctProcessRunner { status: ExitCode::from(output.status), }) } + + fn run_until( + &self, + program: &str, + args: &[&str], + working_dir: &Utf8Path, + stop: &dyn Fn(&str) -> bool, + ) -> Result<(ProcessOutput, Stopped), std::io::Error> { + let handle = cmd(program, args) + .dir(working_dir) + .unchecked() + .stderr_to_stdout() + .reader()?; + + let mut reader = BufReader::new(&handle); + let mut collected = String::new(); + let mut line = String::new(); + let mut stopped = Stopped::No; + + loop { + line.clear(); + // Lossy for the same reason the captured path is: one stray byte + // must not discard the rest of the output. + let mut bytes = Vec::new(); + if reader.read_until(b'\n', &mut bytes)? == 0 { + break; + } + line.push_str(&String::from_utf8_lossy(&bytes)); + collected.push_str(&line); + + if stop(&line) { + stopped = Stopped::Yes; + interrupt(&handle); + break; + } + } + + // A killed process has no status of its own worth reporting, and the + // caller already knows it was killed. + let status = match handle.try_wait() { + Ok(Some(output)) => ExitCode::from(output.status), + _ => ExitCode::from(None), + }; + + Ok(( + ProcessOutput { + stdout: collected, + stderr: String::new(), + status, + }, + stopped, + )) + } } #[cfg(test)] diff --git a/.config/jp/tools/src/debug_jp/util/trace_parse.rs b/.config/jp/tools/src/util/trace.rs similarity index 90% rename from .config/jp/tools/src/debug_jp/util/trace_parse.rs rename to .config/jp/tools/src/util/trace.rs index 5777177f8..08c71279f 100644 --- a/.config/jp/tools/src/debug_jp/util/trace_parse.rs +++ b/.config/jp/tools/src/util/trace.rs @@ -1,8 +1,13 @@ -//! Parser for `JP_DEBUG=1` JSON-per-line trace logs. +//! Parser for JSON-per-line trace logs. //! -//! Each line is a `tracing-subscriber::fmt::json()`-formatted event. -//! We keep parsing tolerant: a malformed line is skipped, not fatal, so a -//! single truncated trailing line doesn't lose the whole report. +//! Each line is a `tracing-subscriber::fmt::json()`-formatted event: what `jp` +//! writes under `JP_DEBUG=1`, and what the macOS app writes to its own trace +//! file. +//! Two tool families read this format and neither owns it, so the parser lives +//! here rather than beside either of them. +//! +//! Parsing is tolerant: a malformed line is skipped, not fatal, so a single +//! truncated trailing line doesn't lose the whole report. use serde::Deserialize; use serde_json::{Map, Value}; @@ -150,5 +155,5 @@ struct RawSpan { } #[cfg(test)] -#[path = "trace_parse_tests.rs"] +#[path = "trace_tests.rs"] mod tests; diff --git a/.config/jp/tools/src/debug_jp/util/trace_parse_tests.rs b/.config/jp/tools/src/util/trace_tests.rs similarity index 100% rename from .config/jp/tools/src/debug_jp/util/trace_parse_tests.rs rename to .config/jp/tools/src/util/trace_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/.jp/config/personas/app-dev.toml b/.jp/config/personas/app-dev.toml new file mode 100644 index 000000000..949736e74 --- /dev/null +++ b/.jp/config/personas/app-dev.toml @@ -0,0 +1,54 @@ +extends = [ + "dev.toml", + "../skill/debug-app.toml", +] + +[assistant] +name = "App Developer" + +[[assistant.instructions]] +title = "macOS App Development Workflow" +description = """\ +The `dev` workflow still applies. These are the parts that only come up once a running app is \ +involved.\ +""" +items = [ + """\ + **Reach for a Swift test first.** Anything that can be exercised in-process belongs in \ + `apps/macos/Tests`, where it runs in milliseconds and keeps working without a display. Driving \ + a running app is for what only a running app shows: window and menu behaviour, state \ + restoration, scroll performance, and the AppKit complaints in `apps/macos/QA.md`.\ + """, + """\ + **Verify in order: `swift_format`, `swift_check`, `swift_test`, then drive.** A driven run \ + builds the app anyway, so reaching for `debug_app_launch` before the tests pass only moves the \ + failure somewhere slower to read.\ + """, + """\ + **A change to what the app displays usually has two halves.** The data comes through the \ + `jp_ffi` C ABI, so a new field means a Rust payload and a Swift mirror that nothing checks \ + against each other. Pin the exact JSON on both sides.\ + """, + """\ + **Stop the app when you are done, and close any profile bracket first.** A left-running \ + instance holds a window on the user's screen and blocks the next `debug_app_launch`; a \ + left-open bracket is an `xctrace` still recording, which is worse. If a task ends with either \ + still up, say so explicitly rather than leaving it to be discovered.\ + """, + """\ + **Read the console on every snapshot, not just when something looks wrong.** A correct-looking \ + accessibility tree sitting on a reentrancy warning is a defect that ships. The first section of \ + `QA.md` is entirely this channel.\ + """, + """\ + **Let the app's own trace ask the question before the profiler answers it.** Every snapshot \ + reports the intervals the app timed and its footprint, which is enough to say *what* is slow. \ + Reach for `debug_app_profile` when you need the code responsible, and bracket the one operation \ + rather than the session — a bracket held open across an idle app reports the shared cache.\ + """, + """\ + **Check what a driven run actually isolates before trusting it with destructive state.** A \ + slot owns its own bundle identifier, so recents and window state are its own. Anything keyed \ + somewhere else is not, and the way to find out is to look rather than to assume.\ + """, +] diff --git a/.jp/config/personas/dev.toml b/.jp/config/personas/dev.toml index 8035d43ef..fbb1284f0 100644 --- a/.jp/config/personas/dev.toml +++ b/.jp/config/personas/dev.toml @@ -12,6 +12,7 @@ extends = [ "../skill/edit-files.toml", "../skill/git-reading.toml", "../skill/rust-development.toml", + "../skill/swift-development.toml", "../skill/github-reader.toml", "../skill/unix.toml", "../skill/coding.toml", diff --git a/.jp/config/personas/rfd-implementor.toml b/.jp/config/personas/rfd-implementor.toml index 1315ec014..27acb64a2 100644 --- a/.jp/config/personas/rfd-implementor.toml +++ b/.jp/config/personas/rfd-implementor.toml @@ -10,6 +10,7 @@ extends = [ "../skill/edit-files.toml", "../skill/git-reading.toml", "../skill/rust-development.toml", + "../skill/swift-development.toml", "../skill/github-reader.toml", "../skill/project-discourse.toml", "../skill/unix.toml", diff --git a/.jp/config/skill/debug-app.toml b/.jp/config/skill/debug-app.toml new file mode 100644 index 000000000..da2eba6fc --- /dev/null +++ b/.jp/config/skill/debug-app.toml @@ -0,0 +1,203 @@ +[[assistant.system_prompt_sections]] +tag = "debug_app_skill" +title = "Skill: macOS App Debugging" +content = """\ +You have been given the macOS app debugging skill. You are an expert at driving the native app in \ +`apps/macos` and observing what it does, against a workspace and an app state directory that are \ +not the user's. The following tools help you with this: + +- debug_app_launch: Build the app and launch an isolated instance, recording it as the current \ +session. Returns whatever the app wrote to its console while starting. +- debug_app_snapshot: Read the running app's accessibility tree, plus the console output since the \ +last call and, on request, the pasteboard. Reads only. +- debug_app_screenshot: Write a PNG of the app's frontmost window and return its path. Reads only. +- debug_app_pixels: Read the colours along one row or column of that window, as runs of identical \ +pixels. Reads only. +- debug_app_drive: Run a list of steps — select a row, press a button, perform an accessibility \ +action, walk the menu bar, type into a field — reading the accessibility tree after every step and \ +reporting what each one changed. +- debug_app_profile: Open an Instruments recording around one operation, close it to get a \ +symbolicated time profile, and report on what a session recorded. +- debug_app_quit: Stop the app, keeping its state so it can be relaunched into. + +The app is a long-lived GUI process, so the *running instance is the session*. `debug_app_launch` \ +records which process it started; every later call verifies that record and fails loudly when it \ +no longer matches — the app was quit outside these tools, or something else launched it. When that \ +happens, read the error rather than retrying: it names what to do. + +Only one instance is addressable at a time. `debug_app_launch` refuses while a recorded app is \ +still running, so stop it with `debug_app_quit` first. + +The app is launched in the background and does not take keyboard focus, so a driven run does not \ +interrupt what the user is typing. That rules out the `click` step, which synthesizes a real mouse \ +event and needs the window frontmost — use `select`, `press`, or `perform` instead, which go \ +through the accessibility tree and work on a background app. + +The exception is the `menu` step, which brings the app forward and therefore takes focus from \ +whatever had it. It has no choice: AppKit disables every menu item that acts on the front window or \ +the responder chain while the app is in the background, which is most of the menu bar. Say so when \ +a task involves one. + +Four observation channels, and none sees the others' failures. The accessibility tree says what \ +the interface structurally *is*, which is what tells you whether a change had the effect you \ +expected. The console says what AppKit objected to, and a whole class of defect — reentrancy \ +warnings, layout constraint complaints, exceptions — appears there and nowhere in the tree. A \ +snapshot that looks right can still be sitting on a console full of warnings, so read both. + +Console output is reported as a delta: each call answers "what happened since I last looked", not \ +"what happened all run". An empty console section means nothing new, not nothing at all. + +The trace is the third channel, and the only one that says how long anything took. The app times \ +its own work — launch to first window, opening a workspace, selecting a conversation, rendering a \ +transcript, the view bodies around them — and samples its memory footprint at the end of each \ +interval. A snapshot summarizes what was written since the last call: how many intervals, the \ +slowest one, and the footprint with its change. It is a delta like the console, so the numbers \ +answer "what did the last thing I did cost". The lines themselves are at \ +`tmp/debug-app//state/trace.jsonl`, in the same JSON-per-line format `jp` writes under \ +`JP_DEBUG=1`; read them directly when the summary is not enough. The same intervals go out as \ +signposts, so a run can be opened in Instruments instead. + +That channel says how long something took. `debug_app_profile` is the escalation to *why*, and the \ +only one that names the code responsible. It is a bracket rather than a mode: open it, drive the \ +one operation in question, close it. Keep it tight — a bracket held open across a mostly-idle app \ +reports the dyld shared cache, which is what an app does when nobody is driving it, and a session \ +can hold several brackets in sequence so there is no reason to stretch one. + +`debug_app_profile` with `mode: "report"` reads both tiers back, and is the tool to reach for \ +before either of the other two modes. Read-only, idempotent, works with no app running and after \ +`debug_app_quit`. Its default view attributes what the app timed to the driven step that caused \ +it — `debug_app_drive` writes a line per step for exactly this — so a five-selection drive followed \ +by one report call says which selection is expensive before anything has been recorded with \ +Instruments at all. `view: "hotspots"` and `view: "callgraph"` then name the code, from a closed \ +bracket's bundle. + +Counts, not milliseconds. View-body evaluations and FFI call counts are deterministic for the same \ +steps, so `against: ` compares two runs on them and a fix can be asserted against them. \ +Wall clock is noisy within a run and not comparable between two, so diffing two profiles on \ +milliseconds chases ghosts and the comparison views leave it out. + +When a bracket opens decides both what it can see and what closing it costs, and nothing else \ +does. With a session running the recorder attaches to that process alone and closing takes a \ +second or two. With no session there is nothing to attach to, so it records every process on the \ +machine: the only way to cover the app's own startup, and minutes to close, because every \ +process's samples are exported before the app's can be sifted out of them. Open a bracket before \ +`debug_app_launch` only when the question is about launch itself. + +Allocation attribution is reachable in exactly one arrangement, and two constraints leave no \ +other. The Allocations instrument refuses a target of all processes, so it cannot be recorded in \ +the no-app scope; and it reads what libmalloc kept, which only happens for an app launched with \ +`MallocStackLogging`. So: `debug_app_launch` with `allocation_stacks: true`, then a bracket \ +attached to that app. Every other combination is refused rather than recording an instrument that \ +would find nothing. + +Scope decides what survives a bracket, as it decides everything else here. A system-wide bundle \ +embeds the environment of every process on the machine, so closing extracts a summary and deletes \ +it. An attach bundle embeds only the app's, so it is kept and can be re-read at a different scope \ +— which is what makes `mode: "report"` able to answer a second question. Either way, one left \ +behind by an interrupted bracket is reclaimed without being asked, and retention is bounded by an \ +age window and a byte budget with the oldest evicted first. Never commit a bundle or attach one to \ +a bug report. + +The app's own stream is archived rather than truncated at each launch, so the per-step counts from \ +an earlier run stay readable. That is what `against` compares against, and it is worth more than \ +the bundles: those counts live there and nowhere else. + +Everything a run touches is scoped to a *slot*, named by `JP_DEBUG_APP_SLOT` and defaulting to \ +`default`. A slot owns its session record, its state and conversation-store directories, its \ +console files, its scratch workspace, and its own copy of the app bundle under \ +`tmp/debug-app//`. Two agents that set different slots do not collide; two that share one \ +will, and it shows up as a session record vanishing mid-sequence. + +The bundle copy is what isolates window state. `@SceneStorage` is keyed by bundle identifier \ +rather than by environment, so each slot's copy carries its own identifier and neither the \ +developer's app nor another agent restores its windows. The recent-workspace list is isolated the \ +same way, plus a file the app writes under the slot's state directory. + +The pasteboard is the fourth channel, and it is off by default because it belongs to the whole \ +system rather than to the app: leaving it on would put whatever the user last copied into every \ +snapshot. Ask for it when checking something the app was told to copy, such as the `jp://` URIs \ +that Copy Link writes one per line. + +`debug_app_launch` opens an empty scratch workspace by default, which is deterministic but has no \ +conversations to select. Pass `workspace` when the behaviour under test needs real data — but a \ +driven instance sees fewer conversations there than `jp` does. Conversations live in two roots, \ +the workspace store and the user-local silo, and the slot's empty user-data directory replaces the \ +second. A count lower than expected is that isolation, not a broken reader. Pass \ +`fresh = false` on a relaunch to keep the previous run's state, which is the only way to observe \ +what the app restores — with it, the app reopens what it had and ignores the workspace argument. + +`appearance` forces light or dark whatever the machine is set to, and is the only way to see the \ +half of a palette nobody has looked at. A colour that resolves correctly per appearance in a unit \ +test says nothing about whether the view drawing it picked the right one. + +`debug_app_drive` stops at the first failing step, because the steps after one that failed were \ +written against a state the app never reached. Its report is the evidence for whether each step had \ +the effect it was written for: the tree delta and the console output, per step. + +A reading after every step is the expensive part of a driven run, so scope one with `identifier` \ +when the steps act on a single region. There is no step that waits a fixed duration: use \ +`wait_for` against an identifier the app publishes, and if there is no such identifier, the app is \ +missing one. + +That expense is the app's, not just the driver's, which makes `reads: "none"` the first thing to \ +reach for when a run is meant to *measure* rather than to watch. The app answers a reading on the \ +thread it draws on, and an unscoped one walks every element it publishes — thousands, for a \ +transcript, in proportion to how much of a conversation is on screen. A profile of a driven run \ +with readings on is partly a profile of the readings, scaled by the very thing usually under \ +study: the same resize sweep is visibly sluggish with them on and smooth with them off. A prefix \ +that matches *nothing* is the worst case, because the search has no early exit. + +Measure by comparing counts between two recordings, never by converting samples to milliseconds: \ +the sampling interval is not reported and assuming one turns a count into a number that reads like \ +a measurement and is not. Record a control — a case known to be fast, or none of the work at all — \ +before recording the case in question, and compare the two with `against`. An absolute count from \ +a single run says nothing. + +A report that names no symbols in the app's own binary is usually not a broken symbolicator. It \ +means the samples landed in system frameworks, which the dyld shared cache carries no symbols for, \ +and that is itself the answer: the time is in `SwiftUI` or CoreText rather than in code this \ +repository owns. + +`debug_app_snapshot` reads the tree through `jpdrive`, which needs the terminal to hold macOS's \ +Accessibility grant. A `not_permitted` failure is that grant missing, not a broken app. + +`debug_app_screenshot` is the escalation path for what the tree cannot express: markdown \ +rendering, scroll bar proportions, truncation, colour, overlapping views. That is a handful of \ +the items in `QA.md`; everything else is cheaper and sharper as text, and a screenshot-first loop \ +costs an order of magnitude more tokens per observation while answering fewer of the questions \ +that come up. It needs the Screen Recording grant, which is a separate grant from the \ +Accessibility one, and it returns a path rather than an image: a tool result is text all the way \ +to the provider, so say what the file shows only after the human attaches it with `jp query -a \ +`. + +`debug_app_pixels` reads the same screen without needing anyone to attach anything, and is the \ +escalation to reach for first. The tree carries a frame for every element, which settles where a \ +field or a row sits; a divider, a selection fill, a row separator and a rounded border are not \ +elements, so they have no frame to ask for and no colour to report. A scanline has all four in it, \ +as runs of identical pixels, so an edge is a number and a colour is a value. + +In pixels, not points, which is what keeps the retina factor visible: a one-point line reads as a \ +run of two, and "it should be 2px" is answerable rather than ambiguous. Colours come back in the \ +image's own colour space, named in the report, because the same screenshot read as Display P3 and \ +as sRGB gives two different sets of values for the same pixels. + +So: a scan when the question is what colour something is or how wide it is, a screenshot when the \ +question is whether the whole thing looks right. A scan also re-reads a capture an earlier call \ +left behind, via `image`, which is the way to ask two questions about one picture without the app \ +changing in between. + +Prefer a narrow snapshot to a whole-application one: pass `identifier` with a prefix \ +(`sidebar.`, `transcript.`) and a small `max_matches`. Identifiers sit on leaves, so an unfiltered \ +read walks every element in the app. `apps/macos/AFFORDANCES.md` lists the identifiers the app \ +publishes, and `apps/macos/QA.md` lists the behaviour no automated test covers, which is what \ +these tools exist to reach.\ +""" + +[conversation.tools] +debug_app_launch = { enable = true, run = "unattended" } +debug_app_snapshot = { enable = true, run = "unattended" } +debug_app_screenshot = { enable = true, run = "unattended" } +debug_app_pixels = { enable = true, run = "unattended" } +debug_app_drive = { enable = true, run = "unattended" } +debug_app_profile = { enable = true, run = "unattended" } +debug_app_quit = { enable = true, run = "unattended" } diff --git a/.jp/config/skill/swift-development.toml b/.jp/config/skill/swift-development.toml new file mode 100644 index 000000000..44fd81f2c --- /dev/null +++ b/.jp/config/skill/swift-development.toml @@ -0,0 +1,246 @@ +[[assistant.system_prompt_sections]] +tag = "swift_development_skill" +title = "Skill: Swift Development" +content = """\ +You have been given the Swift development skill. You are an expert at writing Swift code for the \ +native macOS app in `apps/macos`, which reads JP conversations through the `jp_ffi` C ABI. The \ +following tools help you with this: + +- swift_check: Build the app, reporting compiler diagnostics. +- swift_test: Run the fast suites — the app's unit tests and the `jpdrive` package. +- swift_test_ui: Run named UI tests, which drive the app through the screen. Stops at the first \ +failure unless `CI` is set. +- swift_format: Format the app's Swift sources, or report violations without rewriting. + +Each build tool brings its own inputs up to date first (the `jp_ffi` static library, its generated \ +C header, and the Xcode project), so no setup step is needed. + +The Xcode project is generated from `apps/macos/project.yml` and is not committed. Change the \ +manifest, never the generated `.xcodeproj`. +""" + +[conversation.tools] +swift_check = { enable = true, run = "unattended", result = "unattended", style.inline_results = "off", style.results_file_link = "off" } +swift_test = { enable = true, run = "unattended", result = "unattended", style.inline_results = "full", style.results_file_link = "off" } +swift_test_ui = { enable = true, run = "ask", result = "unattended", style.inline_results = "full", style.results_file_link = "off" } +swift_format = { enable = true, run = "unattended", result = "unattended", style.inline_results = "off", style.results_file_link = "off" } + +[[assistant.instructions]] +title = "Swift Project Conventions" +items = [ + """\ + The app is held to the same bar as the Rust code: strict compiler modes, warnings as errors, \ + and tests for every behavior. There is no "it's only the UI" exemption.\ + """, + """\ + Swift 6 language mode with complete concurrency checking is on, and warnings are errors. Do \ + not silence a diagnostic with `@unchecked Sendable`, `nonisolated(unsafe)`, or an `@preconcurrency` \ + import without saying in a comment why the code is actually safe.\ + """, + """\ + Add doc comments (`///`) for every type, method, and property, and clarify non-obvious logic \ + with inline comments (`//`). The audience is a reader who has never seen the FFI boundary.\ + """, + """\ + Tests live in `apps/macos/Tests`, use swift-testing (`import Testing`, `@Test`, `#expect`), and \ + reach app-internal types through `@testable import JP`.\ + """, + """\ + UI tests live in `apps/macos/UITests` and run in their own process, so `@testable import JP` \ + is not available and must not be reached for. Anything checkable in-process belongs in \ + `apps/macos/Tests`, where it runs in milliseconds instead of costing an app launch.\ + """, + """\ + No test may touch the *system* pasteboard. A debug build copies wherever `JP_DEBUG_PASTEBOARD` \ + names, so a test points the app at a private pasteboard and reads that back; \ + `ClipboardPolicyTests` fails on any spelling of the general one.\ + """, + """\ + Anything a UI test needs the app to do differently goes through `DebugState`, gated on \ + `#if DEBUG` and off unless an environment variable says otherwise — the pasteboard and \ + animations both work this way. A release build must have no way to reach the test behaviour.\ + """, + """\ + A new animation goes through `DebugState.animated(_:)`. 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, and one lever turning them all off is worth keeping whole.\ + """, + """\ + Wait on a condition, never on the clock, and never through `waitForExistence`: it reports an \ + element about a second after it appears, whatever the element. `AppUnderTest.wait(for:)` asks \ + again instead, which finds it in under 100ms, and the timeout (one second) is the price of a \ + failure rather than of a pass.\ + """, + """\ + A UI suite shares one launched app through `.sharedApp(...)`, because launching costs about \ + four seconds and the work under test costs milliseconds. Leave the app as you found it, or \ + order the suite so what one test leaves is what the next expects. A test needing an untouched \ + app launches its own and says why.\ + """, + """\ + Run `swift_format` and `swift_check` before considering a change done, and `swift_test` before \ + considering it correct. A change to the UI suite also needs the tests you touched run by name \ + through `swift_test_ui`; the whole bundle is CI's job.\ + """, + """\ + Anything published through `@FocusedValue` or `focusedSceneValue` must compare equal to itself \ + between renders. A struct of closures never does, and the App-body loop that follows re-renders \ + the whole scene continuously: menus lose the items AppKit injects, and the entire app turns \ + sluggish.\ + """, + """\ + `accessibilityIdentifier` does not reach the `NSMenuItem` SwiftUI bridges a menu button to, \ + on the button or on its label, so every item in a menu reports the same selector name and is \ + addressable only by title.\ + """, + """\ + A `RawRepresentable` silently inherits behaviour from its raw value: `Codable` that encodes \ + through `rawValue`, which recurses forever if `rawValue` uses `JSONEncoder`, and `==` that \ + compares raw values, which is unstable when the raw value is JSON holding a dictionary. Encode \ + a separate private `Codable` struct, and write `==` out by hand.\ + """, +] + +[[assistant.instructions]] +title = "Swift Code Style Rules" +description = """\ +Hard rules for Swift in this project. The goal is code that reasons like the Rust it calls into: \ +ownership, errors, and concurrency all visible at the use site. Violating any of these is a bug.\ +""" +items = [ + """\ + **Use typed throws.** Write `throws(WorkspaceError)`, not bare `throws`. A caller should know \ + what can go wrong from the signature. Give a `do` block an explicit `do throws(E)` when its \ + `catch` needs the concrete type rather than `any Error`.\ + """, + """\ + **Model unique ownership with `~Copyable`.** A type that owns a resource requiring exactly one \ + release (an FFI handle, a file descriptor) is a noncopyable struct with a `deinit`, not a class. \ + That makes a double free a compile error instead of a crash.\ + """, + """\ + **Never force unwrap or force try.** No `!`, no `try!`, no implicitly unwrapped optionals. Use \ + `guard let`, `if let`, or a typed throw. The formatter enforces this; do not work around it.\ + """, + """\ + **Spell existentials `any P`.** The `ExistentialAny` upcoming feature is on, so a witness table \ + lookup is visible at the use site. Prefer a generic parameter when the concrete type is known.\ + """, + """\ + **Copy owned data across the FFI boundary.** Read the bytes out of a C pointer, hand the pointer \ + straight back to the library that allocated it, and never let a pointer, a lock guard, or a \ + borrow outlive the call that produced it.\ + """, + """\ + **Keep FFI calls off the main thread.** The Rust side takes locks and touches the filesystem. \ + Wrap reads in a detached task and return `Sendable` values from it.\ + """, + """\ + **Treat Swift mirrors of Rust types as a contract.** A `Decodable` struct mirroring a Rust \ + payload has no compiler checking it agrees. Pin the exact JSON in a test on both sides, and \ + ignore unknown keys so a field added in Rust does not break an older app.\ + """, + """\ + **Use early exits.** `guard` for preconditions at the top of a function rather than nesting the \ + body inside an `if`.\ + """, +] + +[[assistant.instructions.examples]] +good = """\ +init(path: String) throws(WorkspaceError) { + guard let handle = jp_workspace_open(path) else { + throw Self.lastError() + } + self.handle = handle +}\ +""" +bad = """\ +init(path: String) throws { + self.handle = jp_workspace_open(path)! +}\ +""" +reason = """\ +The typed throw tells the caller what can fail; the force unwrap turns a reported error into a \ +crash and discards the message the library left behind.\ +""" + +[[assistant.instructions.examples]] +good = """\ +struct WorkspaceReader: ~Copyable { + private let handle: OpaquePointer + + deinit { jp_workspace_close(handle) } +}\ +""" +bad = """\ +final class WorkspaceReader { + private let handle: OpaquePointer + + deinit { jp_workspace_close(handle) } +}\ +""" +reason = """\ +Noncopyable gives one owner and one release, checked at compile time. The class relies on ARC and \ +lets a second reference outlive the close.\ +""" + +[[assistant.instructions.examples]] +good = """\ +private static func take(_ raw: UnsafeMutablePointer) -> Data { + defer { jp_string_free(raw) } + return Data(bytes: raw, count: strlen(raw)) +}\ +""" +bad = """\ +private static func take(_ raw: UnsafeMutablePointer) -> String { + return String(cString: raw) +}\ +""" +reason = """\ +The good version copies the bytes and returns the allocation to the library that made it. The bad \ +version leaks, because Rust frees what Rust allocates.\ +""" + +[[assistant.instructions.examples]] +good = """\ +let read = Task.detached { () -> Result<[ConversationSummary], WorkspaceError> in + do throws(WorkspaceError) { + let reader = try WorkspaceReader(path: path) + return .success(try reader.conversations()) + } catch { + return .failure(error) + } +} +return await read.value\ +""" +bad = """\ +return await Task.detached { + do { + return .success(try WorkspaceReader(path: path).conversations()) + } catch { + return .failure(error) + } +}.value\ +""" +reason = """\ +The explicit closure return type and `do throws(E)` give `catch` a concrete error. Without them the \ +binding is `any Error` and needs a downcast.\ +""" + +[[assistant.instructions.examples]] +good = """\ +/// When the conversation was last activated, as RFC 3339 text. +/// +/// Deliberately unparsed: the Rust side emits fractional seconds whenever the +/// stored timestamp has them, which `JSONDecoder`'s `.iso8601` strategy rejects. +let lastActivatedAt: String\ +""" +bad = """\ +@available(*, deprecated) +let lastActivatedAt: Date\ +""" +reason = """\ +The comment records the constraint that makes the odd-looking type correct. A future reader would \ +otherwise "fix" it to `Date` and break every real workspace.\ +""" diff --git a/.jp/mcp/tools/debug_app/drive.toml b/.jp/mcp/tools/debug_app/drive.toml new file mode 100644 index 000000000..ba7345401 --- /dev/null +++ b/.jp/mcp/tools/debug_app/drive.toml @@ -0,0 +1,130 @@ +[conversation.tools.debug_app_drive] +enable = false +format = "unattended" +run = "ask" +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Run a list of steps against the running macOS app, reporting what each step changed in the accessibility tree and what the app wrote to its console. Changes the app. macOS only." + +examples = """ +Select a conversation and wait for its transcript to replace the spinner: +```json +{"steps": [ + {"select": {"identifier": "sidebar.row.17800836875"}}, + {"wait_for": {"identifier": "transcript.scroll", "timeout_ms": 10000}} +]} +``` + +Type into the filter box and see what the list narrows to, reading only the +sidebar so each step costs one small walk: +```json +{"steps": [{"type": {"identifier": "sidebar.filter", "text": "rfd"}}], + "identifier": "sidebar.", "max_matches": 50} +``` + +Close the front window through the menu bar, then look again once AppKit has +finished with it: +```json +{"steps": [{"menu": {"path": ["File", "Close"]}}, {"snapshot": {}}]} +``` +""" + +[conversation.tools.debug_app_drive.style] +parameters = "just serve-tools {{context}} {{tool}}" +inline_results = "full" +results_file_link = "off" + +[conversation.tools.debug_app_drive.parameters.steps] +required = true +type = "array" +summary = "Steps to run, in order. Stops at the first failure." +description = """ +Each step is a single-key object naming what to do: + +- `{"select": {"identifier": "…"}}` — write `AXSelected` on the nearest ancestor \ +that accepts it. The mechanism for list and outline rows, where the identified \ +element sits below the one that owns selection. Reaches a row that is not on \ +screen. +- `{"press": {"identifier": "…"}}` — `AXPress` the element itself. The \ +mechanism for buttons and menu items. +- `{"perform": {"identifier": "…", "action": "AXShowMenu"}}` — any named \ +accessibility action, for the long tail beyond press. +- `{"menu": {"path": ["File", "Close"]}}` — press a menu item by the titles \ +leading to it. Brings the app forward first, and so takes focus from whatever \ +had it: AppKit disables every item acting on the front window or the responder \ +chain while the app is in the background, which is most of the menu bar. Add \ +`"under": "sidebar.list"` to start from the menu that element is showing rather \ +than the menu bar — open it first with `AXShowMenu`, in the same call, since it \ +closes as soon as the app deactivates. Titles are the only way in, because \ +SwiftUI gives every item in a context menu the same accessibility identifier. +- `{"type": {"identifier": "…", "text": "…"}}` — put text into a field. +- `{"resize": {"identifier": "…", "width": 1400, "height": 900}}` — write \ +`AXSize`, which for a window resizes it. One write rather than a gesture, so it \ +never enters live resize: use it to measure what a settled layout costs, and \ +`drag` for what happens during one. A window clamps to its own limits, so the \ +step reports the size it reached and whether that is what was asked for. +- `{"drag": {"identifier": "…", "from": {"dx": 1.0, "dy": 0.5}, "to": {"dx": \ +0.6, "dy": 0.5}, "steps": 24, "pause_ms": 8}}` — hold the button down and move \ +across an element, `dx` and `dy` being fractions of its frame. The only step that \ +produces a gesture rather than a state: a window edge dragged rather than \ +resized, a split divider moved, a stretch of text selected. What a view does \ +*during* a drag can differ from what it does after one, and every other step here \ +sees only the after. Needs the window frontmost, like `click`, so it takes focus. \ +`steps` is how many moves are posted between the endpoints, and is the point of \ +the step — one jump exercises a single frame. +- `{"wait_for": {"identifier": "…", "under": "…", "timeout_ms": 5000}}` — block \ +until an element appears. Set `under` to the container it will appear in: an \ +unscoped search for something absent reads every element in the app, which can \ +exhaust the timeout in a single attempt. +- `{"click": {"identifier": "…"}}` — synthesize a mouse click. The last resort: \ +it needs the window frontmost and the element on screen, which a driven app \ +launched in the background is not. +- `{"snapshot": {}}` — act on nothing and read again, for an effect that lands \ +after the step that caused it. + +There is no step that waits a fixed duration. Waiting and assuming the work \ +finished is a guess; if a wait cannot be written as `wait_for` against an \ +identifier, the app is missing that identifier and adding it is the fix. + +The whole list is checked before the first step runs, because a list abandoned \ +halfway leaves the app in a state nobody asked for. +""" +items = { type = "object" } + +[conversation.tools.debug_app_drive.parameters.reads] +type = "string" +summary = "Whether to read the tree between steps: `every_step` (the default) or `none`." +description = """ +`none` is for measuring the app rather than watching it. A reading is the \ +driver's work but the *app* answers it, on the thread it draws on, and an \ +unscoped one walks every element the app publishes — which for a transcript is \ +thousands, in proportion to how much of a conversation is on screen. + +That makes a profile of a driven run partly a profile of the readings, scaled by \ +the very thing usually under study. A resize sweep with readings on is visibly \ +sluggish and a sweep with them off is not, on the same app and the same steps. + +With `none` a step still reports what it did and what the app wrote to its \ +console. It reports nothing about the tree, rather than claiming it did not \ +change. +""" + +[conversation.tools.debug_app_drive.parameters.identifier] +type = "string" +summary = "Read only the elements whose accessibility identifier starts with this, and the ancestors leading to them." +description = """ +Scopes every reading the run takes. Without it the whole application is walked \ +after each step, which for a large workspace is thousands of elements per step. \ +Pass the prefix the steps act under — `sidebar.`, `transcript.` — when the run \ +is about one region. +""" + +[conversation.tools.debug_app_drive.parameters.max_matches] +type = "integer" +summary = "How many matches each reading stops at. Defaults to 5. Only applies with `identifier`." +description = """ +Identifiers sit on leaves, so a prefix search cannot prune on the way down and \ +an unbounded one reads every element in the application. The default of 5 is \ +enough to see the shape of a region, but a delta cannot show a change to the \ +sixth row of a list: raise it when the run is about which of many rows moved. +""" diff --git a/.jp/mcp/tools/debug_app/launch.toml b/.jp/mcp/tools/debug_app/launch.toml new file mode 100644 index 000000000..3a846b588 --- /dev/null +++ b/.jp/mcp/tools/debug_app/launch.toml @@ -0,0 +1,108 @@ +[conversation.tools.debug_app_launch] +enable = false +format = "unattended" +run = "ask" +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Build the macOS app and launch an isolated instance that later `debug_app_*` calls address. Leaves a running GUI application behind. macOS only." + +description = """ +The app instruments itself on every run, so a launch always produces timings: \ +`debug_app_snapshot` reports the intervals it recorded and its memory \ +footprint. Reach for `debug_app_profile` when those numbers raise a question \ +those intervals cannot answer. + +Launching into an already-open profile bracket is how the app's own startup \ +gets covered. +""" + +examples = """ +Launch against a scratch workspace with no conversations: +```json +{} +``` + +Launch against this checkout, to drive a workspace with real conversations: +```json +{"workspace": "."} +``` + +Relaunch after a quit, keeping the previous run's state so window and workspace +restoration can be observed: +```json +{"fresh": false} +``` + +Launch an app a profile bracket can record allocations against, accepting that +every timing in the session becomes incomparable with one recorded without it: +```json +{"allocation_stacks": true} +``` + +Launch in dark appearance, whatever the machine is set to, to check a palette's +other half: +```json +{"workspace": ".", "appearance": "dark"} +``` +""" + +[conversation.tools.debug_app_launch.style] +parameters = "just serve-tools {{context}} {{tool}}" +inline_results = "full" +results_file_link = "off" + +[conversation.tools.debug_app_launch.parameters.allocation_stacks] +type = "boolean" +summary = "Whether the app keeps a stack for every allocation it makes. Defaults to false." +description = """ +Passes `MallocStackLogging` to the app, which libmalloc reads at process start. \ +That makes this the only moment the decision can be taken: an app launched \ +without it can never report allocation stacks, and `debug_app_profile` refuses \ +to record allocations against one. + +Off by default because it costs 2x to 10x and not evenly — allocation-heavy \ +paths slow disproportionately, so one operation looking 3x another may mean \ +only that it allocates more. Every timing in such a session is comparable only \ +with another one like it, including the intervals the app reports about \ +itself. Ask for it when the question is about memory, not when it is about \ +time. +""" + +[conversation.tools.debug_app_launch.parameters.workspace] +type = "string" +summary = "Workspace the app should open. Defaults to a scratch workspace at `tmp/debug-app/workspace`." +description = """ +Relative paths resolve against the repository root. The scratch default is \ +created on first use with an empty store, which is deterministic but has no \ +conversations to select — pass a real workspace when the behaviour under test \ +needs them. +""" + +[conversation.tools.debug_app_launch.parameters.appearance] +type = "string" +summary = "Appearance to draw in: `light` or `dark`. Follows the machine when unset." +description = """ +Passed as `-AppleInterfaceStyle`, which reaches the app's argument domain and \ +outranks the system setting. Both values are named explicitly, so `light` forces \ +light on a machine set to dark rather than merely leaving the choice alone. + +The lever for checking the half of a palette nobody has looked at. A colour that \ +resolves correctly per appearance in a unit test still says nothing about \ +whether the view drawing it picked the right one. +""" + +[conversation.tools.debug_app_launch.parameters.configuration] +type = "string" +summary = "Xcode configuration to build. Defaults to `Debug`." + +[conversation.tools.debug_app_launch.parameters.fresh] +type = "boolean" +summary = "Whether to empty the app's state directory first. Defaults to true." +description = """ +When `true`, the app starts with no recent-workspace list, which is what makes \ +it open the workspace named above. + +When `false`, the previous run's state is kept and the app reopens whatever it \ +had open, ignoring the workspace argument. This is the only way to observe \ +state restoration across a quit and relaunch. +""" diff --git a/.jp/mcp/tools/debug_app/pixels.toml b/.jp/mcp/tools/debug_app/pixels.toml new file mode 100644 index 000000000..4d84b0bf5 --- /dev/null +++ b/.jp/mcp/tools/debug_app/pixels.toml @@ -0,0 +1,92 @@ +[conversation.tools.debug_app_pixels] +enable = false +format = "unattended" +run = "ask" +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Read the colours along one row or column of the running macOS app's window, as runs of identical pixels. Reads only. macOS only." + +description = """ +The escalation from the accessibility tree for anything *drawn*. A tree read \ +gives the frame of every element, which settles where a text field or a row \ +sits; a divider, a selection fill, a row separator and a rounded border are not \ +elements, so they have no frame to ask for and no colour to report. A scanline \ +across the window has all four in it. + +Offsets and colours are in **pixels**, not points. That is the unit these \ +questions arrive in, and it keeps the retina factor visible rather than hiding \ +it: a one-point line reads as a run of two. The window's size is reported in \ +both units, so the conversion is at hand. + +Colours are the image's own, unconverted, and the colour space is named in the \ +report. A screenshot read as Display P3 and as sRGB gives two different sets of \ +values for the same pixels. + +Each call captures a fresh PNG and keeps it, so a second question about the same \ +picture can pass `image` instead of capturing again. + +Needs the Screen Recording grant, which is a different grant from the \ +Accessibility one the rest of these tools need. +""" + +examples = """ +Is the line between the sidebar and the transcript one uniform colour, and how +wide is it? The sidebar is 280 points wide, so scan across the boundary: +```json +{"scan": "row", "at": 200, "from": 540, "to": 580} +``` + +Is the selected row filled with the colour the palette names, or is the system +accent showing through? Scan down through it: +```json +{"scan": "column", "at": 100, "from": 60, "to": 200} +``` + +The same capture, read again at another line, without disturbing the app: +```json +{"scan": "row", "at": 400, "image": "tmp/debug-app/default/shot-1785762174129.png"} +``` +""" + +[conversation.tools.debug_app_pixels.style] +parameters = "just serve-tools {{context}} {{tool}}" +inline_results = "full" +results_file_link = "off" + +[conversation.tools.debug_app_pixels.parameters.scan] +type = "string" +required = true +summary = "Which way to read: `row` for left to right, `column` for top to bottom." + +[conversation.tools.debug_app_pixels.parameters.at] +type = "integer" +required = true +summary = "The row or column to read, in pixels from the top or the left." + +[conversation.tools.debug_app_pixels.parameters.from] +type = "integer" +summary = "Where along the scan to start, in pixels. The near edge when unset." +description = """ +A window is a couple of thousand pixels across and the interesting part is \ +usually tens of them. Bounding the scan keeps the report short without moving \ +the offsets, which stay absolute so they can be compared against a frame from \ +`debug_app_snapshot`. +""" + +[conversation.tools.debug_app_pixels.parameters.to] +type = "integer" +summary = "Where along the scan to stop, inclusive, in pixels. The far edge when unset." + +[conversation.tools.debug_app_pixels.parameters.image] +type = "string" +summary = "A PNG to scan instead of capturing a fresh one." +description = """ +For asking a second question about a capture an earlier call left behind, at \ +another line or another range. Nothing about the app is touched, so this also \ +works after `debug_app_quit`, and it is the way to read a screenshot without \ +letting the app change underneath the two scans. + +Relative paths resolve against the repository root. No retina factor is reported \ +for an image passed this way: it may be a crop, a scaled copy, or from another \ +machine. +""" diff --git a/.jp/mcp/tools/debug_app/profile.toml b/.jp/mcp/tools/debug_app/profile.toml new file mode 100644 index 000000000..a877c960b --- /dev/null +++ b/.jp/mcp/tools/debug_app/profile.toml @@ -0,0 +1,272 @@ +[conversation.tools.debug_app_profile] +enable = false +format = "unattended" +run = "ask" +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Open and close an Instruments recording around an operation in the running macOS app, and read back what a session recorded. Start it, drive the operation, stop it for a symbolicated time profile; report to ask what the app cost per driven step. macOS only." + +description = """ +Three modes over one subject: `start` and `stop` record, `report` reads back. + +## Recording is a bracket + +Not something a session carries from launch to quit. Drive the app into the \ +state you want, open a bracket, drive the one operation in question, close it, \ +read the summary. A session can hold several in sequence, so the same operation \ +can be measured warm and cold without relaunching. + +Keep the bracket tight. A bracket held open across a mostly-idle app produces a \ +report dominated by system idle frames, which is the failure this shape exists \ +to avoid. + +**When you open it decides what it can see, and how long closing it takes.** + +With `debug_app_launch` already run, the recorder attaches to that one process. \ +The trace holds it alone and closing the bracket takes a second or two. This is \ +what you want almost always. + +With no app running, there is nothing to attach to, so the recorder takes every \ +process on the machine. That is the only way to cover the app's own startup — \ +open the bracket, then `debug_app_launch` into it — but closing it takes \ +**minutes**, because every process's samples are exported before the app's can \ +be sifted out of them. Only do this when the question is specifically about \ +launch, and note that allocations cannot be recorded this way. + +Scope also decides what survives. An attach bundle holds this app's environment \ +alone, so it is kept and `mode: "report"` can ask it further questions. A \ +system-wide bundle holds every process's, so it is destroyed as soon as it is \ +read and only the summary survives. Whatever is kept is bounded by an age window \ +and a byte budget, oldest evicted first. + +Symbols come from the app's own dSYM and the slide it reports for itself, so \ +frames in the app's code are named and located. Frames in the dyld shared cache \ +are not, and the summary counts them rather than listing addresses. + +## Reporting reads back + +`mode: "report"` changes nothing and can be called repeatedly at different \ +scopes. It needs no running app and no session: reading a run after \ +`debug_app_quit` works from what the slot kept. + +**Counts lead, times support.** View-body evaluations and FFI call counts are \ +deterministic for the same steps, so two runs can be compared on them and a fix \ +can be asserted against them. Wall clock cannot — it is noisy within a run and \ +not comparable between two — so a report leads on the count and carries the \ +duration beside it. Diff two profiles on milliseconds and you are chasing \ +ghosts. + +Three views come from the app's own intervals, which every run writes and which \ +are readable while the app is still going: + +- `timeline` (the default) — per-driven-step counts. Needs a `debug_app_drive` \ + run, which is the only thing that records when a step ran. +- `spans` — every interval the app timed, by how often it ran. +- `views` — the view bodies alone. + +Three come from a finalized `.trace`, so they answer for closed recordings only: + +- `hotspots` — the busiest program counters, named and located. +- `callgraph` — top functions, or the callees of one named with `function`. +- `allocations` — the footprint the app measured for itself over the recording. \ + Not per call site: the Allocations instrument writes to the trace event store \ + rather than to a table and `xctrace export` surfaces none of it, so the stacks \ + are reachable only by opening the bundle in Instruments. The footprint needs no \ + instrument at all and every run has it. + +Every report names the calls that answer the next question, so start with the \ +default view and follow what it suggests. +""" + +examples = """ +What the selections you just drove cost the app, per step: +```json +{"mode": "report"} +``` + +Which view bodies ran under one of those steps, and how often: +```json +{"mode": "report", "view": "views", "step": 5} +``` + +Profile one operation against a running app: +```json +{"mode": "start"} +``` +then drive the operation, then: +```json +{"mode": "stop"} +``` + +Name the code responsible, once that bracket is closed: +```json +{"mode": "report", "view": "hotspots", "function": "deserialize"} +``` + +Check whether a fix moved the counts, against an earlier recording: +```json +{"mode": "report", "recording": "profile-1785748475000", "against": "profile-1785740000000"} +``` + +Cover the app's own startup, accepting a slow close. Open this before +`debug_app_launch`: +```json +{"mode": "start"} +``` + +Add allocation attribution. Needs an app launched with +`allocation_stacks: true`, and cannot be combined with the no-app scope: +```json +{"mode": "start", "capture": ["allocations"]} +``` + +Throw a botched bracket away without paying to read it: +```json +{"mode": "stop", "discard": true} +``` +""" + +[conversation.tools.debug_app_profile.style] +parameters = "just serve-tools {{context}} {{tool}}" +inline_results = "full" +results_file_link = "off" + +[conversation.tools.debug_app_profile.parameters.mode] +type = "string" +enum = ["start", "stop", "report"] +required = true +summary = "`start` opens a recording, `stop` closes it and returns the summary, `report` reads back what this session recorded." +description = """ +`report` is read-only and idempotent: it captures nothing, destroys nothing, and \ +leaves the offsets `debug_app_snapshot` uses to report deltas alone. Call it as \ +often as you like at different scopes. It also works with no app running and \ +after `debug_app_quit`. + +Every other parameter belongs to exactly one mode, and asking for one in the \ +wrong mode is refused rather than ignored. +""" + +[conversation.tools.debug_app_profile.parameters.capture] +type = "array" +items.type = "string" +summary = "Extra instruments to record, on top of the time profile every recording holds. The only value is `allocations`. Applies to `mode: \"start\"`." +description = """ +Sampling is not expressible here, because it is not optional: every recording \ +holds a time profile, so there is nothing to switch on. + +`allocations` adds the Allocations instrument, and it works in exactly one \ +situation: an app launched with `allocation_stacks: true`, with the bracket \ +attached to it. Two constraints leave no other option. The instrument refuses \ +a target of all processes, so it cannot be used in the no-app scope; and it \ +reads what libmalloc recorded, which only happens if the app was started with \ +`MallocStackLogging`. Both other combinations are refused rather than \ +recording an instrument that would find nothing. + +**What it produces is a bundle for a human to open, not data a report can \ +read.** `xctrace export` surfaces none of the Allocations instrument's output \ +— Apple's position is that Leaks and Allocations are built on a different \ +recording technology — so `mode: "report"` can point you at the retained \ +bundle and no further. + +It also costs 2x to 10x and not evenly: allocation-heavy paths slow \ +disproportionately, so every timing in the bracket becomes comparable only \ +with another allocations recording. + +So ask for it only when you intend to open Instruments yourself. For a memory \ +number an agent can act on, skip this entirely and use `mode: "report"` with \ +`view: "allocations"`, which reports the footprint the app samples on every \ +run. +""" + +[conversation.tools.debug_app_profile.parameters.discard] +type = "boolean" +summary = "Throw the recording away unread instead of summarizing it. Defaults to false. Applies to `mode: \"stop\"`." +description = """ +For a bracket that went wrong — the wrong operation, or one that never ran. \ +Reading a system-wide recording costs minutes, and there is no reason to pay \ +that for a trace nobody wants. +""" + +[conversation.tools.debug_app_profile.parameters.view] +type = "string" +enum = ["timeline", "spans", "views", "hotspots", "callgraph", "allocations"] +summary = "Which question the report answers. Defaults to `timeline`. Applies to `mode: \"report\"`." +description = """ +`timeline`, `spans` and `views` read the app's own intervals, which every run \ +writes and which are readable while a bracket is still recording. `hotspots`, \ +`callgraph` and `allocations` read a finalized `.trace`, so they answer for \ +closed recordings only; asked of an open bracket they say so rather than showing \ +an empty table. + +Start with `timeline`. It attributes counts to the driven step that caused them, \ +which is the only view that says *which action* is expensive rather than which \ +code. +""" + +[conversation.tools.debug_app_profile.parameters.recording] +type = "string" +summary = "Scope the report to one recording, named by its id or a unique suffix of one. Applies to `mode: \"report\"`." +description = """ +For a stream-backed view this narrows the window to the bracket. For a \ +bundle-backed view it picks which bundle to read, and can be omitted when the \ +slot holds exactly one readable recording. + +A suffix is enough, because a report abbreviates ids and the abbreviation is \ +what gets pasted back. An ambiguous one is refused with the candidates. +""" + +[conversation.tools.debug_app_profile.parameters.against] +type = "string" +summary = "Render the view as deltas against an earlier recording, named the same way as `recording`. Applies to `mode: \"report\"`." +description = """ +This is the reason counts lead. Comparing two runs on view-body and FFI counts is \ +meaningful — the same steps produce the same counts, so a change is a change in \ +the code. Comparing them on milliseconds is not, so wall clock is left out of the \ +comparison entirely and the bundle-backed views refuse it: their numbers are \ +sample counts, which are time. + +The earlier run's intervals survive because a launch archives the previous \ +stream rather than truncating it. +""" + +[conversation.tools.debug_app_profile.parameters.step] +type = "integer" +summary = "Scope to one driven step, by its position in the run counting from one. Applies to `mode: \"report\"` with a stream-backed view." +description = """ +Step numbers come from `debug_app_drive`, which is the only thing that records \ +when a step ran. Without a driven run there is nothing to scope to, and a report \ +says so. + +A number that names nothing lists the steps there are. +""" + +[conversation.tools.debug_app_profile.parameters.since] +type = "string" +summary = "Start of the window: a duration back from now (`30s`, `5m`, `2h`) or an RFC 3339 timestamp. Applies to `mode: \"report\"` with a stream-backed view." + +[conversation.tools.debug_app_profile.parameters.until] +type = "string" +summary = "End of the window, in the same two forms as `since`. Applies to `mode: \"report\"` with a stream-backed view." + +[conversation.tools.debug_app_profile.parameters.span] +type = "string" +summary = "Keep only intervals whose name holds this. Applies to `mode: \"report\"` with a stream-backed view." +description = """ +Matched as a substring of the name the app gave the work — `transcript.render`, \ +`ConversationHistoryView.body`, `deserialize`. Use `function` instead to narrow a \ +bundle-backed view to a symbol. +""" + +[conversation.tools.debug_app_profile.parameters.function] +type = "string" +summary = "Narrow to one symbol in the app's binary. Applies to `mode: \"report\"` with `view: \"hotspots\"` or `view: \"callgraph\"`." +description = """ +In `hotspots` this keeps only frames whose resolved name holds it. In `callgraph` \ +it switches the view from top functions to what that function was calling at the \ +moment each sample was taken. +""" + +[conversation.tools.debug_app_profile.parameters.top] +type = "integer" +summary = "How many rows a bundle-backed table shows. Defaults to 25. Applies to `mode: \"report\"` with `view: \"hotspots\"` or `view: \"callgraph\"`." + diff --git a/.jp/mcp/tools/debug_app/quit.toml b/.jp/mcp/tools/debug_app/quit.toml new file mode 100644 index 000000000..bf65e149b --- /dev/null +++ b/.jp/mcp/tools/debug_app/quit.toml @@ -0,0 +1,27 @@ +[conversation.tools.debug_app_quit] +enable = false +format = "unattended" +run = "ask" +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Stop the app started by `debug_app_launch`, keeping its state so it can be relaunched into. Returns the console output since the last call. macOS only." + +description = """ +A profile bracket left open is closed first, before the app, and its summary is \ +reported here. Closing a system-wide bracket takes minutes, so a quit that \ +seems to hang is usually that — close the bracket yourself with \ +`debug_app_profile` beforehand if you want to know which step is costing the \ +time. +""" + +examples = """ +Stop the running app: +```json +{} +``` +""" + +[conversation.tools.debug_app_quit.style] +parameters = "just serve-tools {{context}} {{tool}}" +inline_results = "full" +results_file_link = "off" diff --git a/.jp/mcp/tools/debug_app/screenshot.toml b/.jp/mcp/tools/debug_app/screenshot.toml new file mode 100644 index 000000000..13aecfcf8 --- /dev/null +++ b/.jp/mcp/tools/debug_app/screenshot.toml @@ -0,0 +1,19 @@ +[conversation.tools.debug_app_screenshot] +enable = false +format = "unattended" +run = "ask" +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Write a PNG of the running macOS app's frontmost window and return its path. The image reaches the assistant only when a human attaches the file on a following turn. macOS only." + +examples = """ +A picture of the window as it stands: +```json +{} +``` +""" + +[conversation.tools.debug_app_screenshot.style] +parameters = "just serve-tools {{context}} {{tool}}" +inline_results = "full" +results_file_link = "off" diff --git a/.jp/mcp/tools/debug_app/snapshot.toml b/.jp/mcp/tools/debug_app/snapshot.toml new file mode 100644 index 000000000..45bbbfbc5 --- /dev/null +++ b/.jp/mcp/tools/debug_app/snapshot.toml @@ -0,0 +1,108 @@ +[conversation.tools.debug_app_snapshot] +enable = false +format = "unattended" +run = "ask" +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Read the accessibility tree of the running macOS app, plus whatever it has written to its console since the last call, a summary of the intervals it timed and what it now occupies in memory, and optionally the pasteboard. Reads only. macOS only." + +examples = """ +The whole interface, and any console output since the last call: +```json +{} +``` + +Just the sidebar, to see which conversation is selected: +```json +{"identifier": "sidebar.", "max_matches": 1} +``` + +The transcript, with the actions each element advertises, to find what can be +pressed: +```json +{"identifier": "transcript.", "actions": true} +``` + +Check what a Copy Link put on the clipboard: +```json +{"identifier": "sidebar.", "max_matches": 1, "pasteboard": true} +``` +""" + +[conversation.tools.debug_app_snapshot.style] +parameters = "just serve-tools {{context}} {{tool}}" +inline_results = "full" +results_file_link = "off" + +[conversation.tools.debug_app_snapshot.parameters.identifier] +type = "string" +summary = "Keep only elements whose accessibility identifier starts with this, and the ancestors leading to them." +description = """ +A prefix rather than an exact match, so `sidebar.` answers "what is under the \ +sidebar". Without it the whole application is walked, which for a large \ +workspace is thousands of elements. +""" + +[conversation.tools.debug_app_snapshot.parameters.max_matches] +type = "integer" +summary = "How many matches to find before stopping a filtered read. Only applies with `identifier`." +description = """ +Identifiers sit on leaves, so a prefix search cannot prune on the way down and \ +an unbounded one reads every element in the application. Use `1` when looking \ +up one identifier already known, or the walk continues past it looking for a \ +second. +""" + +[conversation.tools.debug_app_snapshot.parameters.depth] +type = "integer" +summary = "How deep to walk before reporting a node's children as not walked." +description = """ +A SwiftUI window nests deeply — the wrapper groups between a list and its rows \ +are several levels on their own — so a low cap hides the elements worth seeing. +""" + +[conversation.tools.debug_app_snapshot.parameters.max_siblings] +type = "integer" +summary = "How many children to walk per level. Defaults to 0, meaning all of them." +description = """ +A cap keeps a thousand near-identical sidebar rows from filling the report, but \ +it also hides the row that was just selected. Reading everything is the right \ +default for a snapshot meant to be diffed against another. +""" + +[conversation.tools.debug_app_snapshot.parameters.frames] +type = "boolean" +summary = "Include each element's on-screen frame. Defaults to false." +description = """ +Left out by default because coordinates change whenever a window moves or a \ +list scrolls, which turns every diff between two snapshots into noise. Useful \ +when the question is about layout rather than structure. +""" + +[conversation.tools.debug_app_snapshot.parameters.menus] +type = "boolean" +summary = "Walk into the menu bar. Defaults to false." +description = """ +Most of the menu bar belongs to macOS rather than to the app — the Apple menu, \ +Services, the window tiling submenus — and walking it adds some two hundred \ +lines around the handful describing the window. Pass this when the question is \ +about a menu. +""" + +[conversation.tools.debug_app_snapshot.parameters.pasteboard] +type = "boolean" +summary = "Report what the pasteboard holds. Defaults to false." +description = """ +The pasteboard is system-wide rather than the app's, so it is off by default: \ +it would otherwise put whatever the user last copied into every snapshot. Pass \ +this when checking something the app was asked to copy. +""" + +[conversation.tools.debug_app_snapshot.parameters.actions] +type = "boolean" +summary = "Include the actions each element advertises. Defaults to false." +description = """ +Actions say what an element can be asked to do — a menu item advertises \ +`AXPress` where a list row does not. Constant per element, so they add bulk \ +without adding signal to a diff. +""" diff --git a/.jp/mcp/tools/swift/check.toml b/.jp/mcp/tools/swift/check.toml new file mode 100644 index 000000000..23a8701ac --- /dev/null +++ b/.jp/mcp/tools/swift/check.toml @@ -0,0 +1,26 @@ +[conversation.tools.swift_check] +enable = false +run = "unattended" +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Build the macOS app in `apps/macos`, validating that the Swift code compiles. The project treats warnings as errors, so anything reported is a failure. Builds the `jp_ffi` static library and regenerates the Xcode project first, so no setup step is needed." + +examples = """ +```json +{} +``` +Builds the Debug configuration. + +```json +{"configuration": "Release"} +``` +""" + +[conversation.tools.swift_check.style] +inline_results = "full" +results_file_link = "off" +parameters = "function_call" + +[conversation.tools.swift_check.parameters.configuration] +summary = "Xcode build configuration to build. Defaults to `Debug`." +type = "string" diff --git a/.jp/mcp/tools/swift/format.toml b/.jp/mcp/tools/swift/format.toml new file mode 100644 index 000000000..67b717141 --- /dev/null +++ b/.jp/mcp/tools/swift/format.toml @@ -0,0 +1,27 @@ +[conversation.tools.swift_format] +enable = false +run = "unattended" +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Format the macOS app's Swift sources with `swift format`, using `apps/macos/.swift-format`. Set `check` to report violations without rewriting anything." + +examples = """ +```json +{} +``` +Rewrites files in place. + +```json +{"check": true} +``` +Reports violations and fails if any are found. +""" + +[conversation.tools.swift_format.style] +inline_results = "full" +results_file_link = "off" +parameters = "function_call" + +[conversation.tools.swift_format.parameters.check] +summary = "Report formatting and lint violations instead of rewriting files. Defaults to `false`." +type = "boolean" diff --git a/.jp/mcp/tools/swift/test.toml b/.jp/mcp/tools/swift/test.toml new file mode 100644 index 000000000..7a09fccac --- /dev/null +++ b/.jp/mcp/tools/swift/test.toml @@ -0,0 +1,41 @@ +[conversation.tools.swift_test] +enable = false +run = "unattended" +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Run the fast Swift test suites: the macOS app's unit tests, and the `jpdrive` accessibility driver package. Unit tests link the `jp_ffi` static library and call it, so a failure there can mean either the Swift or the FFI boundary is broken. Builds the library and regenerates the Xcode project first, so no setup step is needed. The driver package needs neither, and runs first because it is fast. The app's UI tests are deliberately out of reach here — they launch the app and take over the screen; use `swift_test_ui` and name the ones you want." + +examples = """ +```json +{} +``` +Runs everything. + +```json +{"target": "drive"} +``` +Runs the driver package only, skipping the Xcode build entirely. + +```json +{"target": "app", "testname": "WorkspaceReaderTests"} +``` +Runs one suite of the app's unit tests. + +```json +{"target": "drive", "testname": "Tree"} +``` +Runs one suite of the driver package. +""" + +[conversation.tools.swift_test.style] +inline_results = "full" +results_file_link = "off" +parameters = "function_call" + +[conversation.tools.swift_test.parameters.testname] +summary = "Restrict the run to a suite (`SuiteName`) or a single test (`SuiteName/testName()`). Runs everything when unspecified. A nested suite is named by its whole type path, and a swift-testing function keeps its trailing `()`. Pair this with `target`: a name that matches nothing in one of the suites fails the run, and a suite name almost never exists in both." +type = "string" + +[conversation.tools.swift_test.parameters.target] +summary = "Which suites to run: `app` for the macOS app's unit tests, `drive` for the `jpdrive` accessibility driver package, or `all` for both. Defaults to `all`." +type = "string" diff --git a/.jp/mcp/tools/swift/test_ui.toml b/.jp/mcp/tools/swift/test_ui.toml new file mode 100644 index 000000000..150d4216c --- /dev/null +++ b/.jp/mcp/tools/swift/test_ui.toml @@ -0,0 +1,33 @@ +[conversation.tools.swift_test_ui] +enable = false +run = "unattended" +source = "local" +command = "just serve-tools {{context}} {{tool}}" +summary = "Run named UI tests against the macOS app. Each one drives the app through the screen, so a run takes over the display and cannot be used while the machine is busy. Names are required and there is no way to ask for the whole bundle: run the two or three you are working on, and leave the full suite to CI (`just test-app-ui`). The run stops at the first failure and closes the app it left behind; set `CI=1` to let it finish and report everything. Builds the library and regenerates the Xcode project first, so no setup step is needed. A failure reports what the tests recorded and copies their screenshots into `tmp/uitests/`." + +examples = """ +```json +{"tests": ["UISuite/ConversationListTests/clickSelects()"]} +``` +Runs one test. This is the red-green loop: about six seconds. + +```json +{"tests": ["UISuite/ConversationListTests/clickSelects()", "UISuite/ConversationListTests/arrowKeysMoveSelection()"]} +``` +Runs two, in one launch of `xcodebuild`. + +```json +{"tests": ["UISuite/ConversationListTests"]} +``` +Runs a whole suite. Costs a launch of the app per test in it, so reach for +this when finishing a section rather than while iterating. +""" + +[conversation.tools.swift_test_ui.style] +inline_results = "full" +results_file_link = "off" +parameters = "function_call" + +[conversation.tools.swift_test_ui.parameters.tests] +summary = "The suites or tests to run, as `SuiteName` or `SuiteName/testName()`. Required, and at least one. A nested suite is named by its whole type path (`UISuite/ConversationListTests`), and a swift-testing function keeps its trailing `()`. A name matching nothing fails the run rather than being passed over, so a typo is reported instead of quietly shrinking the run. Pass an empty list to be told what there is, asked of the built bundle." +type = "array" diff --git a/Cargo.lock b/Cargo.lock index c87759a0c..a367a8eda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,23 @@ version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ - "gimli", + "gimli 0.31.1", +] + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "cpp_demangle", + "fallible-iterator", + "gimli 0.33.0", + "memmap2", + "object 0.39.1", + "rustc-demangle", + "smallvec", + "typed-arena", ] [[package]] @@ -307,11 +323,11 @@ version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ - "addr2line", + "addr2line 0.24.2", "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.36.7", "rustc-demangle", "windows-targets 0.52.6", ] @@ -720,6 +736,15 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" +[[package]] +name = "cpp_demangle" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1013,7 +1038,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -1147,7 +1172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -1230,7 +1255,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1447,6 +1472,18 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + [[package]] name = "glob" version = "0.3.3" @@ -2375,6 +2412,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" @@ -2988,6 +3044,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "flate2", + "memchr", + "ruzstd", +] + [[package]] name = "ollama-rs" version = "0.3.4" @@ -3753,7 +3820,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -3810,6 +3877,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash", +] + [[package]] name = "ryu" version = "1.0.20" @@ -4482,7 +4558,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -4808,6 +4884,7 @@ dependencies = [ "url", "which", "windows-sys 0.61.0", + "xct2cli", ] [[package]] @@ -4951,6 +5028,12 @@ dependencies = [ "syntect", ] +[[package]] +name = "twox-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" + [[package]] name = "typed-arena" version = "2.0.2" @@ -5321,7 +5404,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -5639,6 +5722,24 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "xct2cli" +version = "0.1.0" +dependencies = [ + "addr2line 0.26.1", + "camino", + "cpp_demangle", + "gimli 0.33.0", + "libc", + "object 0.39.1", + "quick-xml", + "rustc-demangle", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "xml5ever" version = "0.36.1" diff --git a/Cargo.toml b/Cargo.toml index 22986bba1..a06e08736 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,9 @@ comfort = { path = "crates/contrib/comfort" } grizzly = { path = "crates/contrib/grizzly", default-features = false } schematic = { path = "crates/contrib/schematic", default-features = false } ticket = { path = "crates/internal/ticket" } +xct2cli = { path = "crates/contrib/xct2cli" } +addr2line = { version = "0.26" } ahash = { version = "0.8", default-features = false, features = ["runtime-rng", "std", "serde"] } assert_matches = { version = "1", default-features = false } async-anthropic = { git = "https://github.com/JeanMertz/async-anthropic", default-features = false } @@ -60,6 +62,7 @@ clean-path = { version = "0.2", default-features = false } comfy-table = { version = "7", default-features = false } comrak = { version = "0.52", default-features = false } convert_case = { version = "0.11", default-features = false } +cpp_demangle = { version = "0.5", default-features = false } crossbeam-channel = { version = "0.5", default-features = false } crossterm = { version = "0.29", default-features = false } darling = { version = "0.23", default-features = false } @@ -73,6 +76,7 @@ eventsource-stream = { version = "0.2", default-features = false } fancy-regex = { version = "0.17", default-features = false } futures = { version = "0.3", default-features = false } gemini_client_rs = { git = "https://github.com/JeanMertz/gemini-client", default-features = false } # +gimli = { version = "0.33" } glob = { version = "0.3", default-features = false } grep-printer = { version = "0.3", default-features = false } grep-regex = { version = "0.1", default-features = false } @@ -91,6 +95,7 @@ libc = { version = "0.2", default-features = false } linkme = { version = "0.3", default-features = false } maud = { version = "0.27", default-features = false } minijinja = { version = "2", default-features = false } +object = { version = "0.39" } # Pinned to a rev rather than a branch: a branch dependency follows its HEAD on # `cargo update`. Carries the `LocalModel` model-details fields on top of 0.3.4, # which is the last release on reqwest 0.12. 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..63ca14a95 --- /dev/null +++ b/apps/macos/AFFORDANCES.md @@ -0,0 +1,302 @@ +# 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`]) 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 +[`QA.md`]: QA.md diff --git a/apps/macos/QA.md b/apps/macos/QA.md new file mode 100644 index 000000000..0ff5bdb2d --- /dev/null +++ b/apps/macos/QA.md @@ -0,0 +1,384 @@ +# QA checklist + +The behavior in [`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. + +[`AFFORDANCES.md`]: AFFORDANCES.md 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/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..78fcf9224 --- /dev/null +++ b/apps/macos/Tools/jpdrive/README.md @@ -0,0 +1,321 @@ +# 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/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 new file mode 100644 index 000000000..ff3045a04 --- /dev/null +++ b/apps/macos/project.yml @@ -0,0 +1,181 @@ +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) + + # 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 + # 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 + - JPUITests diff --git a/crates/contrib/xct2cli/Cargo.toml b/crates/contrib/xct2cli/Cargo.toml new file mode 100644 index 000000000..52df23e75 --- /dev/null +++ b/crates/contrib/xct2cli/Cargo.toml @@ -0,0 +1,51 @@ +# Vendored from <https://github.com/landaire/xct2cli> at 9ebb2e0. See README.md +# for what differs from upstream. +[package] +name = "xct2cli" +description = "Reads Xcode Instruments .trace bundles into symbolicated hotspots and callgraphs." +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false +version = "0.1.0" + +[lib] +name = "xct2cli" +path = "src/lib.rs" + +[dependencies] +addr2line = { workspace = true } +camino = { workspace = true } +cpp_demangle = { workspace = true } +gimli = { workspace = true } +object = { workspace = true } +quick-xml = { workspace = true, features = ["serialize"] } +rustc-demangle = { workspace = true, features = ["std"] } +serde = { workspace = true, features = ["derive", "rc", "std"] } +serde_json = { workspace = true, features = ["std"] } +thiserror = { workspace = true, features = ["std"] } +tracing = { workspace = true } + +# Only the Swift demangler needs `libc`, and only for `dlopen`. +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } + +# The workspace's pedantic lints would mean a few hundred lines of stylistic +# churn against code we did not write, and every line of that churn is a line +# that conflicts on the next sync. The lints that catch mistakes rather than +# taste stay on. +[lints.rust] +future-incompatible = "warn" +nonstandard-style = "warn" +unused = { level = "warn", priority = -1 } + +[lints.clippy] +all = { level = "warn", priority = -1 } +print_stderr = "deny" +print_stdout = "deny" + +# Style lints upstream does not satisfy. Rewriting a match arm into a guard +# inside a hand-rolled XML state machine risks a silently misparsed trace, and +# the rest is taste. Allowed here so the source stays diffable against upstream. +collapsible_match = "allow" +doc_lazy_continuation = "allow" +unnecessary_sort_by = "allow" diff --git a/crates/contrib/xct2cli/LICENSE-APACHE b/crates/contrib/xct2cli/LICENSE-APACHE new file mode 100644 index 000000000..1d3f09ec5 --- /dev/null +++ b/crates/contrib/xct2cli/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file name or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Lander Brandt + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/contrib/xct2cli/LICENSE-MIT b/crates/contrib/xct2cli/LICENSE-MIT new file mode 100644 index 000000000..d233381c1 --- /dev/null +++ b/crates/contrib/xct2cli/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2026 Lander Brandt + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/crates/contrib/xct2cli/README.md b/crates/contrib/xct2cli/README.md new file mode 100644 index 000000000..04fe9aa2c --- /dev/null +++ b/crates/contrib/xct2cli/README.md @@ -0,0 +1,101 @@ +# xct2cli + +Reads an Xcode Instruments `.trace` bundle and produces symbolicated hotspots +and callgraphs. +Apple Silicon only. + +`xctrace export` does not hand you symbolicated stacks. +A Time Profiler row carries the leaf symbol, a count of the frames it withheld, +and the rest of the stack as raw ASLR-slid integers fragmented across rows that +back-reference each other by id. +Recovering frames means reassembling those fragments, recovering dyld load +addresses from `kdebug` `DBG_DYLD_UUID_MAP_A`/`_B` tracepoint pairs +cross-referenced against the `kdebug-strings` table, and only then symbolicating +against a dSYM. +This crate does all of it, natively through `addr2line`/`gimli`/`object` rather +than by shelling out to `atos`. + +## Provenance + +Vendored from <https://github.com/landaire/xct2cli> at commit `9ebb2e0`, MIT OR +Apache-2.0. +Both licence files are kept alongside this one. + +Vendored rather than depended on: upstream is one author and a handful of +commits, the code is right, and the problem is ours for years. +That does not remove the maintenance burden. +The `.trace` format is undocumented and remains Apple's to change. + +The source is kept close to upstream so it can be re-synced. +Style lints upstream does not satisfy are allowed by name in `Cargo.toml` rather +than fixed, so a diff against a fresh checkout stays readable. + +## What differs from upstream + +- **Swift demangling.** Upstream carries `rustc-demangle` and `cpp_demangle` and + no Swift demangler, so every Swift symbol reports as `$s2JP17Conversation…`. + `symbol::swift` loads `libswiftDemangle.dylib` out of the active Xcode + toolchain with `dlopen` and calls it from the single `demangle` choke point in + `symbol::macho`. + Point `SWIFT_DEMANGLE_DYLIB` at a specific copy to override the search. + A missing dylib degrades to the mangled name. +- **quick-xml 0.41.** Upstream targets 0.39, where `xml_content` and + `normalized_value` take no XML version. + The version is threaded through `xml::XML_VERSION`. +- **Environment redaction.** `redact::strip_environment` runs over everything + `xctrace` produces, at the single point in `xctrace::Xctrace` where its output + is returned. + See "A recorded trace is a secret" below. +- **Removed:** the `xct2cli` binary and its `cli` feature; `render`, which + formatted reports for a terminal; `analysis::annotate`, which annotated + disassembly; and `analysis::{pmi, counters}`, which read hardware performance + counters. + Dropping the first three also drops `capstone`, `annotate-snippets`, and + `owo-colors`. + The counter paths needed kperf, which needs root or + `com.apple.private.kernel.kpc`. + +## Recording a trace it can read + +Debug builds keep debug info in the object files, so the app must be built with +`DEBUG_INFORMATION_FORMAT = dwarf-with-dsym` (set for Debug in +`apps/macos/project.yml`). + +```sh +just build-app Debug +xcrun xctrace record --instrument 'Time Profiler' \ + --time-limit 10s --output /tmp/jp.trace \ + --launch -- /path/to/JP.app +``` + +Three parts of that command are easy to get wrong. + +**`--instrument`, not `--template`.** On Xcode 26 a template produces a trace +that fails export with "Document Missing Template Error". + +**`--launch`, not `--attach`.** Slide recovery reads dyld image loads out of the +`dyld-library-load` and `kdebug` tables, and those only record loads that happen +*during* the window. +A process you attach to loaded its libraries before recording started, so the +tables come back empty, no slide can be recovered, and nothing symbolicates. + +**The `--` is required.** `--launch -- <path>` execs the path. +Without the `--`, xctrace resolves the argument as a name and fails with +"Provided process is ambiguous" whenever more than one copy of the bundle exists +on the machine. + +## A recorded trace is a secret + +A `.trace` bundle embeds the full environment of the process it recorded, and +`xctrace export --toc` prints it. +Recording against a shell-launched process captures every API key and token that +shell exported. + +This crate strips `<environment>` from everything it reads back, so using the +library cannot disclose them. +That does not sanitise the bundle: the values are still on disk inside it, and +anyone running `xctrace export` by hand will see them. +Never commit a recorded bundle, and treat one on disk as credential material. + +The fixtures under `tests/fixtures` are upstream's exported XML, not bundles, +which is why they are safe to keep. diff --git a/crates/contrib/xct2cli/src/address.rs b/crates/contrib/xct2cli/src/address.rs new file mode 100644 index 000000000..773994c66 --- /dev/null +++ b/crates/contrib/xct2cli/src/address.rs @@ -0,0 +1,156 @@ +//! Newtypes for addresses, slides, sample times, core ids, and pids. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// A program counter as observed at runtime (after ASLR slide). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RuntimePc(u64); + +impl RuntimePc { + pub const fn new(addr: u64) -> Self { + Self(addr) + } + pub const fn raw(self) -> u64 { + self.0 + } + /// Subtract a slide to recover the file (preferred) address. + pub fn to_file(self, slide: Slide) -> Option<FilePc> { + self.0.checked_sub(slide.0).map(FilePc) + } +} + +impl fmt::Display for RuntimePc { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "0x{:016x}", self.0) + } +} + +impl fmt::LowerHex for RuntimePc { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::LowerHex::fmt(&self.0, f) + } +} + +/// A program counter as encoded in the Mach-O binary (preferred address, before +/// any ASLR slide is applied at load time). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct FilePc(u64); + +impl FilePc { + pub const fn new(addr: u64) -> Self { + Self(addr) + } + pub const fn raw(self) -> u64 { + self.0 + } + pub fn to_runtime(self, slide: Slide) -> RuntimePc { + RuntimePc(self.0.wrapping_add(slide.0)) + } +} + +impl fmt::Display for FilePc { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "0x{:016x}", self.0) + } +} + +impl fmt::LowerHex for FilePc { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::LowerHex::fmt(&self.0, f) + } +} + +/// ASLR slide: how far the binary's `__TEXT` was shifted at load time. +/// Always page-aligned (0x4000 on Apple Silicon). +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +#[serde(transparent)] +pub struct Slide(u64); + +impl Slide { + pub const ZERO: Self = Self(0); + pub const fn new(offset: u64) -> Self { + Self(offset) + } + pub const fn raw(self) -> u64 { + self.0 + } +} + +impl fmt::Display for Slide { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "0x{:x}", self.0) + } +} + +impl fmt::LowerHex for Slide { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::LowerHex::fmt(&self.0, f) + } +} + +/// A nanosecond timestamp from the trace's `sample-time` column. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SampleTime(u64); + +impl SampleTime { + pub const fn new(ns: u64) -> Self { + Self(ns) + } + pub const fn ns(self) -> u64 { + self.0 + } + pub fn ms(self) -> u64 { + self.0 / 1_000_000 + } +} + +/// Logical CPU id (P-core / E-core index). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CoreId(u32); + +impl CoreId { + pub const fn new(id: u32) -> Self { + Self(id) + } + pub const fn raw(self) -> u32 { + self.0 + } +} + +impl fmt::Display for CoreId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// A POSIX process id. +/// Negative values mean "unknown" in trace context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Pid(i64); + +impl Pid { + pub const fn new(id: i64) -> Self { + Self(id) + } + pub const fn raw(self) -> i64 { + self.0 + } + pub const fn unknown() -> Self { + Self(-1) + } +} + +impl fmt::Display for Pid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/crates/contrib/xct2cli/src/analysis/callgraph.rs b/crates/contrib/xct2cli/src/analysis/callgraph.rs new file mode 100644 index 000000000..1cf4c4443 --- /dev/null +++ b/crates/contrib/xct2cli/src/analysis/callgraph.rs @@ -0,0 +1,260 @@ +//! Top-N functions and per-function callee aggregation, computed from full +//! callstack samples (every `time-sample` row's backtrace, not just the leaf). + +use std::{ + cmp::Ordering, + collections::{BTreeSet, HashMap}, + path::PathBuf, +}; + +use serde::Serialize; + +use crate::{ + address::{Pid, RuntimePc}, + analysis::{Callstack, SlideMode}, + error::Result, + symbol::{BinaryInfo, Symbolicator, SymbolicatorOptions}, + trace::TraceBundle, +}; + +/// Per-function aggregated sample count. +#[derive(Debug, Clone, Serialize)] +pub struct FunctionStat { + pub function: String, + pub samples: u64, + pub fraction: f64, +} + +/// Result of a `CallgraphBuilder::run()` query. +#[derive(Debug, Clone, Serialize)] +pub struct CallgraphReport { + /// What the report measures (`"top functions (inclusive)"` or `"callees of + /// <NAME>"`), included so the renderer can label. + pub view: String, + pub total_samples: u64, + pub stats: Vec<FunctionStat>, +} + +pub struct CallgraphBuilder<'a> { + bundle: &'a TraceBundle, + pid: Option<Pid>, + top_n: usize, + binary: Option<PathBuf>, + dsym: Option<PathBuf>, + slide: SlideMode, + function: Option<String>, +} + +impl<'a> CallgraphBuilder<'a> { + pub fn new(bundle: &'a TraceBundle) -> Self { + Self { + bundle, + pid: None, + top_n: 10, + binary: None, + dsym: None, + slide: SlideMode::default(), + function: None, + } + } + + pub fn pid(mut self, pid: Pid) -> Self { + self.pid = Some(pid); + self + } + pub fn top(mut self, n: usize) -> Self { + self.top_n = n; + self + } + pub fn binary(mut self, path: Option<PathBuf>) -> Self { + self.binary = path; + self + } + pub fn dsym(mut self, path: Option<PathBuf>) -> Self { + self.dsym = path; + self + } + pub fn slide(mut self, mode: SlideMode) -> Self { + self.slide = mode; + self + } + /// When set, the report shows top callees of this function (the next-deeper + /// frame, toward the leaf) instead of the global top-functions view. + pub fn function(mut self, name: Option<String>) -> Self { + self.function = name; + self + } + + pub fn run(self) -> Result<CallgraphReport> { + let stacks = self.bundle.callstacks(self.pid)?; + let symbolicator = self.build_symbolicator()?; + + // Cache PC -> function name (or fall back to `0xADDR`). One + // cache for the whole report so we don't re-resolve identical + // PCs across thousands of stacks. + // + // We use `symbol_at` (binary-symbol lookup) instead of + // `resolve` (DWARF inlining-aware) - for stack-frame analysis we + // want the *concrete* function the PC lives in, not the + // innermost inlined source function. Resolving via DWARF would + // attribute every PC where `Vec::len` was inlined to "Vec::len", + // wrecking the callgraph. + let mut name_cache: HashMap<RuntimePc, String> = HashMap::new(); + let resolve = |pc: RuntimePc, cache: &mut HashMap<RuntimePc, String>| -> String { + if let Some(name) = cache.get(&pc) { + return name.clone(); + } + let name = symbolicator + .as_ref() + .and_then(|s| s.symbol_at(pc)) + .unwrap_or_else(|| format!("0x{:x}", pc.raw())); + cache.insert(pc, name.clone()); + name + }; + + match self.function.clone() { + None => Ok(self.top_inclusive(&stacks, &mut name_cache, resolve)), + Some(needle) => Ok(self.callees_of(&stacks, &needle, &mut name_cache, resolve)), + } + } + + fn build_symbolicator(&self) -> Result<Option<Symbolicator>> { + if self.binary.is_none() && self.dsym.is_none() { + return Ok(None); + } + let slide = match &self.slide { + SlideMode::Manual(s) => *s, + SlideMode::Auto => self + .binary + .as_deref() + .and_then(|bin| BinaryInfo::open(bin).ok()) + .and_then(|info| { + let loads = self.bundle.image_loads().unwrap_or_default(); + info.slide_from(&loads) + }) + .unwrap_or_default(), + }; + Ok(Some(Symbolicator::new(SymbolicatorOptions { + binary: self.binary.clone(), + dsym: self.dsym.clone(), + slide, + })?)) + } + + fn top_inclusive<F>( + self, + stacks: &[Callstack], + cache: &mut HashMap<RuntimePc, String>, + mut resolve: F, + ) -> CallgraphReport + where + F: FnMut(RuntimePc, &mut HashMap<RuntimePc, String>) -> String, + { + // "Inclusive" means: a function counts once per stack it appears + // in, regardless of depth. Dedup per stack via BTreeSet so a + // recursive function isn't counted N times for one sample. + let mut counts: HashMap<String, u64> = HashMap::new(); + for stack in stacks { + let names: BTreeSet<String> = + stack.frames.iter().map(|pc| resolve(*pc, cache)).collect(); + for name in names { + *counts.entry(name).or_insert(0) += 1; + } + } + let total = stacks.len() as u64; + let mut stats: Vec<FunctionStat> = counts + .into_iter() + .map(|(function, samples)| FunctionStat { + function, + samples, + fraction: if total == 0 { + 0.0 + } else { + samples as f64 / total as f64 + }, + }) + .collect(); + stats.sort_by(by_samples); + stats.truncate(self.top_n); + CallgraphReport { + view: "top functions (inclusive)".to_string(), + total_samples: total, + stats, + } + } + + fn callees_of<F>( + self, + stacks: &[Callstack], + needle: &str, + cache: &mut HashMap<RuntimePc, String>, + mut resolve: F, + ) -> CallgraphReport + where + F: FnMut(RuntimePc, &mut HashMap<RuntimePc, String>) -> String, + { + // For each stack, find the *deepest* (closest-to-root) position + // of `needle`. The "callee" is the frame one closer to the leaf + // - i.e. what `needle` was calling at the moment of the sample. + // If `needle` IS the leaf, it has no callee in this sample. + let mut counts: HashMap<String, u64> = HashMap::new(); + let mut matched_samples: u64 = 0; + for stack in stacks { + let mut deepest: Option<usize> = None; + for (i, pc) in stack.frames.iter().enumerate().rev() { + if name_matches(&resolve(*pc, cache), needle) { + deepest = Some(i); + break; + } + } + let Some(idx) = deepest else { continue }; + matched_samples += 1; + if idx == 0 { + continue; + } + let callee = resolve(stack.frames[idx - 1], cache); + *counts.entry(callee).or_insert(0) += 1; + } + let mut stats: Vec<FunctionStat> = counts + .into_iter() + .map(|(function, samples)| FunctionStat { + function, + samples, + fraction: if matched_samples == 0 { + 0.0 + } else { + samples as f64 / matched_samples as f64 + }, + }) + .collect(); + stats.sort_by(by_samples); + stats.truncate(self.top_n); + CallgraphReport { + view: format!("callees of {needle}"), + total_samples: matched_samples, + stats, + } + } +} + +/// Rank two functions: busiest first, name ascending. +/// +/// The name is what makes this a total order. +/// Every frame on one call chain carries that chain's whole sample count, so +/// ties are the common case rather than the exception, and these come out of a +/// `HashMap` whose iteration order differs between processes. +/// Without the second key, truncating to the top N keeps an arbitrary subset of +/// the tie and two reads of one bundle disagree. +fn by_samples(a: &FunctionStat, b: &FunctionStat) -> Ordering { + b.samples + .cmp(&a.samples) + .then_with(|| a.function.cmp(&b.function)) +} + +fn name_matches(haystack: &str, needle: &str) -> bool { + haystack == needle || haystack.contains(needle) +} + +#[cfg(test)] +#[path = "callgraph_tests.rs"] +mod tests; diff --git a/crates/contrib/xct2cli/src/analysis/callgraph_tests.rs b/crates/contrib/xct2cli/src/analysis/callgraph_tests.rs new file mode 100644 index 000000000..8884632d8 --- /dev/null +++ b/crates/contrib/xct2cli/src/analysis/callgraph_tests.rs @@ -0,0 +1,66 @@ +use super::{FunctionStat, by_samples, name_matches}; + +fn stat(function: &str, samples: u64) -> FunctionStat { + FunctionStat { + function: function.to_string(), + samples, + fraction: 0.0, + } +} + +fn ranked(mut stats: Vec<FunctionStat>) -> Vec<String> { + stats.sort_by(by_samples); + + stats.into_iter().map(|stat| stat.function).collect() +} + +#[test] +fn the_busiest_function_ranks_first() { + assert_eq!( + ranked(vec![ + stat("parse", 4), + stat("render", 90), + stat("decode", 12) + ]), + vec!["render", "decode", "parse"] + ); +} + +/// Inclusive counting makes ties the common case rather than the exception: +/// every frame on one call chain carries that chain's whole count. +/// Those counts come out of a `HashMap`, so without the name as a second key +/// the top-N cut keeps an arbitrary subset of the chain and two reads of one +/// trace disagree about which functions are hot. +#[test] +fn a_shared_call_chain_ranks_the_same_whichever_order_it_arrives_in() { + let one = vec![ + stat("main", 469), + stat("dispatch", 469), + stat("applicationDidFinishLaunching", 469), + stat("body", 469), + ]; + let other = vec![ + stat("body", 469), + stat("main", 469), + stat("applicationDidFinishLaunching", 469), + stat("dispatch", 469), + ]; + + assert_eq!(ranked(one.clone()), ranked(other)); + assert_eq!(ranked(one), vec![ + "applicationDidFinishLaunching", + "body", + "dispatch", + "main" + ]); +} + +#[test] +fn a_function_is_matched_whole_or_by_part_of_its_name() { + assert!(name_matches( + "jp_config::partial::partial_opt", + "partial_opt" + )); + assert!(name_matches("deserialize", "deserialize")); + assert!(!name_matches("serialize", "deserialize")); +} diff --git a/crates/contrib/xct2cli/src/analysis/hotspots.rs b/crates/contrib/xct2cli/src/analysis/hotspots.rs new file mode 100644 index 000000000..95ce6d3d0 --- /dev/null +++ b/crates/contrib/xct2cli/src/analysis/hotspots.rs @@ -0,0 +1,401 @@ +//! Per-CPU hotspot aggregation from the `time-sample` table. + +use std::{ + cmp::Ordering, + collections::{BTreeMap, HashMap}, + path::PathBuf, + rc::Rc, +}; + +use serde::Serialize; + +use crate::{ + address::{CoreId, Pid, RuntimePc, SampleTime, Slide}, + error::Result, + symbol::{BinaryInfo, Symbolicator, SymbolicatorOptions}, + trace::TraceBundle, + xml::{ + Cell, + stream::{RowReader, RowReaderEvent}, + }, +}; + +#[derive(Clone, Serialize)] +pub struct HotspotReport { + pub total_samples: u64, + pub per_cpu: BTreeMap<CoreId, CpuStats>, + pub timeline_buckets_ns: u64, + pub timeline: Vec<TimelineBucket>, + pub top_pcs: Vec<Hotspot>, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CpuStats { + pub samples: u64, + pub label: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TimelineBucket { + pub start_ns: u64, + pub end_ns: u64, + pub samples_per_cpu: BTreeMap<CoreId, u64>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Hotspot { + pub pc: RuntimePc, + pub samples: u64, + pub fmt: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub function: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub line: Option<u32>, +} + +/// Where `HotspotsBuilder` should get its slide from. +#[derive(Debug, Clone, Default)] +pub enum SlideMode { + /// Recover from kdebug DBG\_DYLD events when a binary is provided. + #[default] + Auto, + /// Use the explicit slide. + Manual(Slide), +} + +pub struct HotspotsBuilder<'a> { + bundle: &'a TraceBundle, + pid: Option<Pid>, + bucket_ns: u64, + top_n: usize, + time_window_ns: Option<(u64, u64)>, + binary: Option<PathBuf>, + dsym: Option<PathBuf>, + slide: SlideMode, + filter: Option<String>, +} + +impl<'a> HotspotsBuilder<'a> { + pub fn new(bundle: &'a TraceBundle) -> Self { + Self { + bundle, + pid: None, + bucket_ns: 10_000_000, + top_n: 25, + time_window_ns: None, + binary: None, + dsym: None, + slide: SlideMode::default(), + filter: None, + } + } + + /// Substring filter on the resolved function name. + /// Applied after symbolication, before truncating to `top`. + /// Useful for cutting out stdlib/system noise. + pub fn filter(mut self, substring: Option<String>) -> Self { + self.filter = substring; + self + } + + pub fn binary(mut self, path: Option<PathBuf>) -> Self { + self.binary = path; + self + } + + pub fn dsym(mut self, path: Option<PathBuf>) -> Self { + self.dsym = path; + self + } + + pub fn slide(mut self, mode: SlideMode) -> Self { + self.slide = mode; + self + } + + pub fn pid(mut self, pid: Pid) -> Self { + self.pid = Some(pid); + self + } + + pub fn bucket_ns(mut self, ns: u64) -> Self { + self.bucket_ns = ns.max(1); + self + } + + pub fn top(mut self, n: usize) -> Self { + self.top_n = n; + self + } + + pub fn time_window_ns(mut self, start: u64, end: u64) -> Self { + self.time_window_ns = Some((start, end)); + self + } + + pub fn run(self) -> Result<HotspotReport> { + let xml = self + .bundle + .xctrace() + .export_xpath(self.bundle.path(), TIME_SAMPLE_XPATH)?; + let mut reader = RowReader::new(std::io::Cursor::new(xml)); + + let mut total_samples: u64 = 0; + let mut per_cpu: BTreeMap<CoreId, CpuStats> = BTreeMap::new(); + let mut pc_counts: HashMap<RuntimePc, PcAccumulator> = HashMap::new(); + let mut bucket_map: BTreeMap<u64, BTreeMap<CoreId, u64>> = BTreeMap::new(); + let mut origin_ns: Option<u64> = None; + + while let Some(ev) = reader.next_event()? { + let RowReaderEvent::Row(cells) = ev else { + continue; + }; + let Some(sample) = parse_time_sample(&cells) else { + continue; + }; + if let Some(want_pid) = self.pid + && sample.pid != want_pid + { + continue; + } + if let Some((lo, hi)) = self.time_window_ns + && (sample.time.ns() < lo || sample.time.ns() >= hi) + { + continue; + } + let Some(core) = sample.core else { continue }; + + total_samples += 1; + let entry = per_cpu.entry(core).or_default(); + entry.samples += 1; + if entry.label.is_none() { + entry.label = sample.core_label; + } + + let origin = *origin_ns.get_or_insert(sample.time.ns()); + let bucket_key = (sample.time.ns().saturating_sub(origin)) / self.bucket_ns; + *bucket_map + .entry(bucket_key) + .or_default() + .entry(core) + .or_default() += 1; + + if let Some(pc) = sample.pc { + let e = pc_counts.entry(pc).or_insert_with(|| PcAccumulator { + samples: 0, + fmt: sample.pc_fmt.clone(), + }); + e.samples += 1; + } + } + + let mut timeline: Vec<TimelineBucket> = bucket_map + .into_iter() + .map(|(k, samples_per_cpu)| { + let start_ns = k * self.bucket_ns; + TimelineBucket { + start_ns, + end_ns: start_ns + self.bucket_ns, + samples_per_cpu, + } + }) + .collect(); + timeline.sort_by_key(|b| b.start_ns); + + let mut top_pcs: Vec<Hotspot> = pc_counts + .into_iter() + .map(|(pc, acc)| Hotspot { + pc, + samples: acc.samples, + fmt: acc.fmt, + function: None, + file: None, + line: None, + }) + .collect(); + top_pcs.sort_by(by_samples); + // If no filter, truncate immediately to avoid symbolicating PCs + // we won't keep. With a filter we have to symbolicate everything + // before we know what survives. + if self.filter.is_none() { + top_pcs.truncate(self.top_n); + } + + if self.binary.is_some() || self.dsym.is_some() { + let slide = match &self.slide { + SlideMode::Manual(s) => Some(*s), + SlideMode::Auto => match self.binary.as_deref() { + Some(bin) => { + let info = BinaryInfo::open(bin)?; + let loads = match self.bundle.image_loads() { + Ok(v) => v, + Err(e) => { + tracing::warn!("image_loads failed: {e}"); + Vec::new() + } + }; + let s = info.slide_from(&loads); + match s { + Some(s) => { + tracing::info!( + %s, + "auto-detected slide from kdebug DBG_DYLD events" + ); + } + None => { + tracing::warn!( + "could not auto-detect slide; symbols will be wrong. Use \ + `xct2cli slide` to inspect candidates and pass --slide \ + explicitly." + ); + } + } + s + } + None => None, + }, + }; + let opts = SymbolicatorOptions { + binary: self.binary.clone(), + dsym: self.dsym.clone(), + slide: slide.unwrap_or(Slide::ZERO), + }; + let sym = Symbolicator::new(opts)?; + for h in &mut top_pcs { + if let Ok(frame) = sym.resolve(h.pc) { + h.function = frame.function; + h.file = frame.file; + h.line = frame.line; + } + } + } + + if let Some(needle) = &self.filter { + let needle_lower = needle.to_lowercase(); + top_pcs.retain(|h| { + h.function + .as_deref() + .map(|f| f.to_lowercase().contains(&needle_lower)) + .unwrap_or(false) + }); + top_pcs.truncate(self.top_n); + } + + Ok(HotspotReport { + total_samples, + per_cpu, + timeline_buckets_ns: self.bucket_ns, + timeline, + top_pcs, + }) + } +} + +/// Rank two program counters: busiest first, address ascending. +/// +/// The address is what makes this a total order, and a total order is what +/// makes the report reproducible. +/// A real trace has a long tail of counters tied at one or two samples, and +/// these come out of a `HashMap`, whose iteration order differs between +/// processes. +/// Ordering on the sample count alone therefore leaves the ties in an arbitrary +/// sequence, and truncating to the busiest N then keeps an arbitrary subset of +/// them: two reads of the same bundle report different frames. +/// +/// Ascending rather than descending, and not arbitrarily. +/// Any second key would make the order total, but the app's own images load +/// below the dyld shared cache, so preferring low addresses keeps the tied +/// counters that can be given a symbol — which is what a caller asked for. +/// Reversing this stays reproducible and quietly fills the table with addresses +/// instead. +fn by_samples(a: &Hotspot, b: &Hotspot) -> Ordering { + b.samples.cmp(&a.samples).then_with(|| a.pc.cmp(&b.pc)) +} + +const TIME_SAMPLE_XPATH: &str = "/trace-toc/run[@number=\"1\"]/data/table[@schema=\"time-sample\"]"; + +#[derive(Debug)] +struct PcAccumulator { + samples: u64, + fmt: Option<String>, +} + +#[derive(Debug)] +struct ParsedSample { + time: SampleTime, + pid: Pid, + core: Option<CoreId>, + core_label: Option<String>, + pc: Option<RuntimePc>, + pc_fmt: Option<String>, + thread_state: Option<String>, +} + +impl Default for ParsedSample { + fn default() -> Self { + Self { + time: SampleTime::new(0), + pid: Pid::unknown(), + core: None, + core_label: None, + pc: None, + pc_fmt: None, + thread_state: None, + } + } +} + +fn parse_time_sample(cells: &[Rc<Cell>]) -> Option<ParsedSample> { + let mut s = ParsedSample::default(); + for cell in cells { + match cell.element() { + Some("sample-time") => { + s.time = SampleTime::new(cell.as_u64().unwrap_or(0)); + } + Some("thread") => { + if let Some(pid_cell) = cell.find("pid") { + s.pid = Pid::new(pid_cell.as_i64().unwrap_or(-1)); + } + } + Some("core") => { + s.core = cell.as_u64().map(|v| CoreId::new(v as u32)); + s.core_label = cell.fmt().map(str::to_string); + } + Some("thread-state") => { + s.thread_state = cell.text().map(str::to_string); + } + Some("kperf-bt") => { + if let Some(pc_cell) = cell.find("text-address") { + s.pc = pc_cell.as_u64().map(RuntimePc::new); + s.pc_fmt = pc_cell.fmt().map(str::to_string); + } + } + _ => {} + } + } + if s.time.ns() == 0 && s.core.is_none() { + return None; + } + if matches!(s.thread_state.as_deref(), Some("Blocked")) { + return None; + } + Some(s) +} + +impl HotspotReport { + pub fn empty(bucket_ns: u64) -> Self { + Self { + total_samples: 0, + per_cpu: BTreeMap::new(), + timeline_buckets_ns: bucket_ns, + timeline: Vec::new(), + top_pcs: Vec::new(), + } + } +} + +#[cfg(test)] +#[path = "hotspots_tests.rs"] +mod tests; diff --git a/crates/contrib/xct2cli/src/analysis/hotspots_tests.rs b/crates/contrib/xct2cli/src/analysis/hotspots_tests.rs new file mode 100644 index 000000000..8f597b45e --- /dev/null +++ b/crates/contrib/xct2cli/src/analysis/hotspots_tests.rs @@ -0,0 +1,85 @@ +use super::{Hotspot, by_samples}; +use crate::address::RuntimePc; + +fn hotspot(pc: u64, samples: u64) -> Hotspot { + Hotspot { + pc: RuntimePc::new(pc), + samples, + fmt: None, + function: None, + file: None, + line: None, + } +} + +fn ranked(mut hotspots: Vec<Hotspot>) -> Vec<(u64, u64)> { + hotspots.sort_by(by_samples); + + hotspots + .iter() + .map(|hotspot| (hotspot.pc.raw(), hotspot.samples)) + .collect() +} + +#[test] +fn the_busiest_counter_ranks_first() { + assert_eq!( + ranked(vec![ + hotspot(0x1000, 3), + hotspot(0x2000, 90), + hotspot(0x3000, 12) + ]), + vec![(0x2000, 90), (0x3000, 12), (0x1000, 3)] + ); +} + +/// The defect this pins. +/// Counts are accumulated in a `HashMap`, so the order two counters tied on +/// samples arrive in differs between processes. +/// Ranking on the count alone leaves that arbitrary order in place, and +/// truncating to the busiest N then keeps an arbitrary subset of the tie: two +/// reads of the same trace report different frames, and each looks perfectly +/// plausible. +#[test] +fn a_tie_ranks_the_same_whichever_order_it_arrives_in() { + let one = vec![ + hotspot(0x4000, 1), + hotspot(0x1000, 1), + hotspot(0x3000, 1), + hotspot(0x2000, 1), + ]; + let other = vec![ + hotspot(0x2000, 1), + hotspot(0x3000, 1), + hotspot(0x1000, 1), + hotspot(0x4000, 1), + ]; + + assert_eq!(ranked(one.clone()), ranked(other.clone())); + assert_eq!(ranked(one), vec![ + (0x1000, 1), + (0x2000, 1), + (0x3000, 1), + (0x4000, 1) + ]); +} + +/// What the ordering is for: the busiest 2 of a long tail have to be the same 2 +/// every time, or a report cannot be compared with itself. +#[test] +fn truncating_a_tail_keeps_the_same_counters_every_time() { + let tail = |order: [u64; 5]| { + let mut ranked = ranked(order.iter().map(|pc| hotspot(*pc, 1)).collect()); + ranked.truncate(2); + ranked + }; + + assert_eq!(tail([0x5000, 0x1000, 0x4000, 0x2000, 0x3000]), vec![ + (0x1000, 1), + (0x2000, 1) + ]); + assert_eq!(tail([0x3000, 0x2000, 0x1000, 0x5000, 0x4000]), vec![ + (0x1000, 1), + (0x2000, 1) + ]); +} diff --git a/crates/contrib/xct2cli/src/analysis/mod.rs b/crates/contrib/xct2cli/src/analysis/mod.rs new file mode 100644 index 000000000..300ffd6d2 --- /dev/null +++ b/crates/contrib/xct2cli/src/analysis/mod.rs @@ -0,0 +1,9 @@ +//! Higher-level analyses built on `xml::RowReader`. + +pub mod callgraph; +pub mod hotspots; +pub mod samples; + +pub use callgraph::{CallgraphBuilder, CallgraphReport, FunctionStat}; +pub use hotspots::{Hotspot, HotspotReport, HotspotsBuilder, SlideMode}; +pub use samples::{Callstack, PcSample}; diff --git a/crates/contrib/xct2cli/src/analysis/samples.rs b/crates/contrib/xct2cli/src/analysis/samples.rs new file mode 100644 index 000000000..e11bbb46d --- /dev/null +++ b/crates/contrib/xct2cli/src/analysis/samples.rs @@ -0,0 +1,180 @@ +use std::{cmp::Ordering, collections::HashMap, rc::Rc}; + +use crate::{ + address::{Pid, RuntimePc}, + error::Result, + trace::TraceBundle, + xml::{ + Cell, + stream::{RowReader, RowReaderEvent}, + }, +}; + +#[derive(Debug, Clone, Copy)] +pub struct PcSample { + pub pc: RuntimePc, + pub samples: u64, +} + +/// One sampled callstack. +/// `frames[0]` is the innermost (leaf) PC; later indices are progressively +/// deeper in the call chain (callers, caller's caller, ...). +#[derive(Debug, Clone)] +pub struct Callstack { + pub frames: Vec<RuntimePc>, +} + +impl TraceBundle { + /// Per-PC sample counts from the `time-sample` table (running-state rows + /// only). + /// Sorted descending by count, then ascending by address, so the same table + /// always yields the same sequence. + pub fn pc_samples(&self, pid: Option<Pid>) -> Result<Vec<PcSample>> { + let xml = self + .xctrace() + .export_xpath(self.path(), TIME_SAMPLE_XPATH)?; + let mut reader = RowReader::new(std::io::Cursor::new(xml)); + let mut counts: HashMap<RuntimePc, u64> = HashMap::new(); + while let Some(ev) = reader.next_event()? { + let RowReaderEvent::Row(cells) = ev else { + continue; + }; + let mut sample_pid: i64 = -1; + let mut state: Option<&str> = None; + let mut pc: Option<RuntimePc> = None; + for cell in &cells { + match cell.element() { + Some("thread") => { + if let Some(pidc) = cell.find("pid") { + sample_pid = pidc.as_i64().unwrap_or(-1); + } + } + Some("thread-state") => { + state = match cell.as_ref() { + Cell::Leaf(l) => Some(l.text.as_str()), + _ => None, + }; + } + Some("kperf-bt") => { + if let Some(pcc) = cell.find("text-address") { + pc = pcc.as_u64().map(RuntimePc::new); + } + } + _ => {} + } + } + if state == Some("Blocked") { + continue; + } + if let Some(want) = pid + && Pid::new(sample_pid) != want + { + continue; + } + let Some(pc) = pc else { continue }; + *counts.entry(pc).or_insert(0) += 1; + } + let mut out: Vec<PcSample> = counts + .into_iter() + .map(|(pc, samples)| PcSample { pc, samples }) + .collect(); + out.sort_by(by_samples); + Ok(out) + } + + /// Full per-sample callstacks from `time-sample` (running-state rows only). + /// One `Callstack` per row; identical stacks are NOT deduped - the caller + /// can aggregate as needed. + pub fn callstacks(&self, pid: Option<Pid>) -> Result<Vec<Callstack>> { + let xml = self + .xctrace() + .export_xpath(self.path(), TIME_SAMPLE_XPATH)?; + let mut reader = RowReader::new(std::io::Cursor::new(xml)); + let mut out: Vec<Callstack> = Vec::new(); + while let Some(ev) = reader.next_event()? { + let RowReaderEvent::Row(cells) = ev else { + continue; + }; + let mut sample_pid: i64 = -1; + let mut state: Option<&str> = None; + let mut user_bt: Option<&Rc<Cell>> = None; + for cell in &cells { + match cell.element() { + Some("thread") => { + if let Some(pidc) = cell.find("pid") { + sample_pid = pidc.as_i64().unwrap_or(-1); + } + } + Some("thread-state") => { + state = match cell.as_ref() { + Cell::Leaf(l) => Some(l.text.as_str()), + _ => None, + }; + } + Some("kperf-bt") => { + // The schema lists `cp-kernel-callstack` before + // `cp-user-callstack`; for user-mode samples the + // kernel one is sentinel, so the last non-empty + // kperf-bt is the user backtrace we want. + user_bt = Some(cell); + } + _ => {} + } + } + if state == Some("Blocked") { + continue; + } + if let Some(want) = pid + && Pid::new(sample_pid) != want + { + continue; + } + let Some(bt) = user_bt else { continue }; + let Some(frames) = extract_frames(bt) else { + continue; + }; + if frames.is_empty() { + continue; + } + out.push(Callstack { frames }); + } + Ok(out) + } +} + +fn extract_frames(kperf_bt: &Rc<Cell>) -> Option<Vec<RuntimePc>> { + let leaf = kperf_bt + .find("text-address") + .and_then(|c| c.as_u64()) + .map(RuntimePc::new)?; + let mut frames = vec![leaf]; + if let Some(addrs) = kperf_bt.find("text-addresses") + && let Some(text) = addrs.text() + { + for tok in text.split_ascii_whitespace() { + let Ok(v) = tok.parse::<u64>() else { continue }; + if v == 0 { + continue; + } + frames.push(RuntimePc::new(v)); + } + } + Some(frames) +} + +/// Rank two program counters: busiest first, address ascending. +/// +/// The address is the tie-breaker that makes this a total order. +/// Counts are accumulated in a `HashMap`, whose iteration order differs between +/// processes, so ordering on the count alone leaves every tie in an arbitrary +/// sequence — and a long tail of counters tied at one or two samples is what a +/// real trace looks like. +fn by_samples(a: &PcSample, b: &PcSample) -> Ordering { + b.samples.cmp(&a.samples).then_with(|| a.pc.cmp(&b.pc)) +} + +const TIME_SAMPLE_XPATH: &str = "/trace-toc/run[@number=\"1\"]/data/table[@schema=\"time-sample\"]"; + +#[cfg(test)] +#[path = "samples_tests.rs"] +mod tests; diff --git a/crates/contrib/xct2cli/src/analysis/samples_tests.rs b/crates/contrib/xct2cli/src/analysis/samples_tests.rs new file mode 100644 index 000000000..c75c8f79d --- /dev/null +++ b/crates/contrib/xct2cli/src/analysis/samples_tests.rs @@ -0,0 +1,49 @@ +use super::{PcSample, by_samples}; +use crate::address::RuntimePc; + +fn sample(pc: u64, samples: u64) -> PcSample { + PcSample { + pc: RuntimePc::new(pc), + samples, + } +} + +fn ranked(mut samples: Vec<PcSample>) -> Vec<(u64, u64)> { + samples.sort_by(by_samples); + + samples + .iter() + .map(|sample| (sample.pc.raw(), sample.samples)) + .collect() +} + +#[test] +fn the_busiest_counter_ranks_first() { + assert_eq!(ranked(vec![sample(0x1000, 2), sample(0x2000, 40)]), vec![ + (0x2000, 40), + (0x1000, 2) + ]); +} + +/// `pc_samples` promises a sorted sequence, and a sequence whose ties come out +/// in `HashMap` order is not one: the same table would yield a different list +/// on every run. +#[test] +fn a_tie_ranks_the_same_whichever_order_it_arrives_in() { + assert_eq!( + ranked(vec![ + sample(0x3000, 7), + sample(0x1000, 7), + sample(0x2000, 7) + ]), + vec![(0x1000, 7), (0x2000, 7), (0x3000, 7)] + ); + assert_eq!( + ranked(vec![ + sample(0x2000, 7), + sample(0x3000, 7), + sample(0x1000, 7) + ]), + vec![(0x1000, 7), (0x2000, 7), (0x3000, 7)] + ); +} diff --git a/crates/contrib/xct2cli/src/error.rs b/crates/contrib/xct2cli/src/error.rs new file mode 100644 index 000000000..36b732229 --- /dev/null +++ b/crates/contrib/xct2cli/src/error.rs @@ -0,0 +1,63 @@ +use std::path::PathBuf; + +use thiserror::Error; + +pub type Result<T, E = Error> = std::result::Result<T, E>; + +#[derive(Debug, Error)] +pub enum Error { + #[error("trace bundle not found: {0}")] + BundleMissing(PathBuf), + + #[error("`xctrace` not found on PATH or at {0}")] + XctraceMissing(PathBuf), + + #[error("`xctrace {subcommand}` exited with status {status}: {stderr}")] + XctraceFailed { + subcommand: &'static str, + status: std::process::ExitStatus, + stderr: String, + }, + + #[error("XML parse error: {0}")] + Xml(#[from] quick_xml::Error), + + #[error("XML encoding error: {0}")] + XmlEncoding(#[from] quick_xml::encoding::EncodingError), + + #[error("XML escape error: {0}")] + XmlEscape(#[from] quick_xml::escape::EscapeError), + + #[error("XML deserialize error: {0}")] + XmlDe(#[from] quick_xml::DeError), + + #[error("malformed trace XML: {0}")] + Schema(String), + + #[error("unresolved cell reference: id={0}")] + UnresolvedRef(u64), + + #[error("table with schema {0:?} not found in TOC")] + TableMissing(String), + + #[error("Mach-O parse error: {0}")] + MachO(#[from] object::Error), + + #[error("DWARF parse error: {0}")] + Dwarf(#[from] gimli::Error), + + #[error("addr2line error: {0}")] + Addr2Line(String), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error(transparent)] + Utf8(#[from] std::str::Utf8Error), + + #[error(transparent)] + ParseInt(#[from] std::num::ParseIntError), + + #[error(transparent)] + Json(#[from] serde_json::Error), +} diff --git a/crates/contrib/xct2cli/src/lib.rs b/crates/contrib/xct2cli/src/lib.rs new file mode 100644 index 000000000..3e0ccd17e --- /dev/null +++ b/crates/contrib/xct2cli/src/lib.rs @@ -0,0 +1,14 @@ +//! Library + CLI for transforming Xcode Instruments traces. + +pub mod address; +pub mod analysis; +pub mod error; +pub mod redact; +pub mod symbol; +pub mod trace; +pub mod xctrace; +pub mod xml; + +pub use address::{CoreId, FilePc, Pid, RuntimePc, SampleTime, Slide}; +pub use error::{Error, Result}; +pub use trace::TraceBundle; diff --git a/crates/contrib/xct2cli/src/redact.rs b/crates/contrib/xct2cli/src/redact.rs new file mode 100644 index 000000000..c7d872d05 --- /dev/null +++ b/crates/contrib/xct2cli/src/redact.rs @@ -0,0 +1,87 @@ +//! Removes the recorded process's environment from exported XML. +//! +//! Instruments stores every environment variable the profiled process held, and +//! `xctrace export --toc` prints them back. +//! On a developer machine that is a full set of API keys and tokens. +//! Everything this crate receives from `xctrace` passes through +//! [`strip_environment`] before any caller sees it, so there is no path by +//! which the library hands those out. + +/// Opening tag name, without its terminator. +const OPEN: &[u8] = b"<environment"; +/// Closing tag, matched in full. +const CLOSE: &[u8] = b"</environment>"; +/// What a stripped block becomes. +/// Keeps the document parseable and makes the removal visible to anyone reading +/// the output. +const REPLACEMENT: &[u8] = b"<environment redacted=\"true\"/>"; + +/// Replace every `<environment>...</environment>` block in `xml` with an empty, +/// marked element. +/// +/// Self-closing `<environment/>` elements are left as they are; they hold +/// nothing. +/// Elements whose name merely starts with `environment`, such as +/// `<environment-info>`, are not touched. +/// +/// Input that opens a block and never closes it is truncated at that point +/// rather than passed through, so malformed XML cannot become a disclosure. +#[must_use] +pub fn strip_environment(xml: Vec<u8>) -> Vec<u8> { + let mut out = Vec::with_capacity(xml.len()); + let mut rest = xml.as_slice(); + + while let Some(start) = find_element(rest) { + let name_end = start + OPEN.len(); + let Some(tag_end) = find(&rest[name_end..], b">").map(|i| name_end + i) else { + return truncate(out, &rest[..start]); + }; + + if rest[tag_end - 1] == b'/' { + out.extend_from_slice(&rest[..=tag_end]); + rest = &rest[tag_end + 1..]; + continue; + } + + let Some(close) = find(&rest[tag_end..], CLOSE).map(|i| tag_end + i) else { + return truncate(out, &rest[..start]); + }; + + out.extend_from_slice(&rest[..start]); + out.extend_from_slice(REPLACEMENT); + rest = &rest[close + CLOSE.len()..]; + } + + out.extend_from_slice(rest); + out +} + +/// Emit everything before an unterminated block, then stop. +fn truncate(mut out: Vec<u8>, head: &[u8]) -> Vec<u8> { + out.extend_from_slice(head); + out.extend_from_slice(REPLACEMENT); + out +} + +/// Offset of the next `<environment` that is a whole element name rather than +/// the prefix of a longer one. +fn find_element(haystack: &[u8]) -> Option<usize> { + let mut from = 0; + while let Some(offset) = find(&haystack[from..], OPEN) { + let at = from + offset; + match haystack.get(at + OPEN.len()) { + Some(b'>' | b'/') => return Some(at), + Some(c) if c.is_ascii_whitespace() => return Some(at), + _ => from = at + OPEN.len(), + } + } + None +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> { + haystack.windows(needle.len()).position(|w| w == needle) +} + +#[cfg(test)] +#[path = "redact_tests.rs"] +mod tests; diff --git a/crates/contrib/xct2cli/src/redact_tests.rs b/crates/contrib/xct2cli/src/redact_tests.rs new file mode 100644 index 000000000..07150d730 --- /dev/null +++ b/crates/contrib/xct2cli/src/redact_tests.rs @@ -0,0 +1,62 @@ +use super::strip_environment; + +fn strip(xml: &str) -> String { + String::from_utf8(strip_environment(xml.as_bytes().to_vec())).unwrap() +} + +#[test] +fn removes_the_items_but_keeps_the_document() { + let input = "<info><target><process pid=\"42\"/><environment>\n <item \ + key=\"ANTHROPIC_API_KEY\" value=\"sk-ant-secret\"/>\n <item key=\"HOME\" \ + value=\"/Users/jean\"/>\n </environment></target></info>"; + + assert_eq!( + strip(input), + "<info><target><process pid=\"42\"/><environment redacted=\"true\"/></target></info>" + ); +} + +#[test] +fn removes_every_block() { + let input = "<run><environment><item key=\"A\" \ + value=\"1\"/></environment></run><run><environment><item key=\"B\" \ + value=\"2\"/></environment></run>"; + + assert_eq!( + strip(input), + "<run><environment redacted=\"true\"/></run><run><environment redacted=\"true\"/></run>" + ); +} + +#[test] +fn leaves_a_self_closing_element_alone() { + assert_eq!( + strip("<target><environment/></target>"), + "<target><environment/></target>" + ); +} + +/// `<environment-info>` shares a prefix with the element being stripped, and +/// removing it would silently eat unrelated parts of the document. +#[test] +fn leaves_longer_element_names_alone() { + let input = "<environment-info><item key=\"A\" value=\"1\"/></environment-info>"; + assert_eq!(strip(input), input); +} + +/// Truncated XML must not become a way to smuggle the environment through. +/// The items after the unterminated tag are dropped, not passed along. +#[test] +fn truncates_an_unterminated_block() { + let input = "<target><environment><item key=\"ANTHROPIC_API_KEY\" value=\"sk-ant-secret\"/>"; + + let output = strip(input); + assert_eq!(output, "<target><environment redacted=\"true\"/>"); + assert!(!output.contains("sk-ant-secret")); +} + +#[test] +fn leaves_xml_without_an_environment_untouched() { + let input = "<trace-toc><run number=\"1\"><table schema=\"time-sample\"/></run></trace-toc>"; + assert_eq!(strip(input), input); +} diff --git a/crates/contrib/xct2cli/src/symbol/macho.rs b/crates/contrib/xct2cli/src/symbol/macho.rs new file mode 100644 index 000000000..e40cac9f9 --- /dev/null +++ b/crates/contrib/xct2cli/src/symbol/macho.rs @@ -0,0 +1,475 @@ +use std::{ + cmp::Ordering, + collections::{BTreeSet, HashMap}, + path::{Path, PathBuf}, +}; + +use addr2line::Loader; +use object::{Object, ObjectSection, ObjectSegment, ObjectSymbol, SymbolKind}; +use serde::Serialize; + +use crate::{ + address::{FilePc, RuntimePc, Slide}, + analysis::PcSample, + error::{Error, Result}, + symbol::swift, + trace::TraceBundle, + xml::{ + Cell, + stream::{RowReader, RowReaderEvent}, + }, +}; + +#[derive(Debug, Clone, Default)] +pub struct SymbolicatorOptions { + pub binary: Option<PathBuf>, + pub dsym: Option<PathBuf>, + /// Subtracted from runtime PCs before lookup. + /// Use when the binary was loaded with an ASLR offset. + pub slide: Slide, +} + +pub struct Symbolicator { + loader: Option<Loader>, + slide: Slide, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SymbolicatedFrame { + pub address: RuntimePc, + /// The deepest-inlined function this PC actually belongs to - i.e. the + /// source the compiler labels the instruction as coming from. + pub function: Option<String>, + pub file: Option<String>, + pub line: Option<u32>, + pub column: Option<u32>, + /// Outer call sites that inlined this PC, closest-out first. + /// Empty when not inlined; the last entry is the concrete binary function. + pub inlined_into: Vec<InlinedFrame>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct InlinedFrame { + pub function: Option<String>, + pub file: Option<String>, + pub line: Option<u32>, +} + +impl Symbolicator { + pub fn new(opts: SymbolicatorOptions) -> Result<Self> { + let pick = opts.dsym.as_ref().or(opts.binary.as_ref()).cloned(); + let loader = match pick { + Some(p) => Some(load(&p)?), + None => None, + }; + Ok(Self { + loader, + slide: opts.slide, + }) + } + + /// The binary's symbol-table name for this PC + /// (`addr2line::Loader::find_symbol_info`). + /// Unlike `resolve`, this returns the *concrete* binary function containing + /// the PC - not the deepest-inlined source function. + /// Use for stack-frame analysis where you want the real call frame. + pub fn symbol_at(&self, runtime_pc: RuntimePc) -> Option<String> { + let loader = self.loader.as_ref()?; + let probe = runtime_pc.to_file(self.slide)?; + loader + .find_symbol_info(probe.raw()) + .map(|s| demangle(s.name())) + } + + pub fn resolve(&self, runtime_pc: RuntimePc) -> Result<SymbolicatedFrame> { + let mut frame = SymbolicatedFrame { + address: runtime_pc, + function: None, + file: None, + line: None, + column: None, + inlined_into: Vec::new(), + }; + let Some(loader) = &self.loader else { + return Ok(frame); + }; + let Some(probe) = runtime_pc.to_file(self.slide) else { + return Ok(frame); + }; + let probe_raw = probe.raw(); + + // addr2line yields frames innermost-first: the very first frame + // is the deepest inlined function (what the instruction actually + // belongs to), and each subsequent frame is the call site that + // inlined the previous one. The last frame is the outermost + // concrete binary function. + let mut iter = loader + .find_frames(probe_raw) + .map_err(|e| Error::Addr2Line(e.to_string()))?; + let mut frames: Vec<addr2line::Frame<'_, _>> = Vec::new(); + while let Some(f) = iter.next().map_err(|e| Error::Addr2Line(e.to_string()))? { + frames.push(f); + } + + if let Some(innermost) = frames.first() { + if let Some(fun) = innermost.function.as_ref() { + frame.function = demangled_function(fun); + } + if let Some(loc) = innermost.location.as_ref() { + frame.file = loc.file.map(str::to_string); + frame.line = loc.line; + frame.column = loc.column; + } + } + + if frame.function.is_none() + && let Some(sym) = loader.find_symbol_info(probe_raw) + { + frame.function = Some(demangle(sym.name())); + } + + for outer in frames.iter().skip(1) { + frame.inlined_into.push(InlinedFrame { + function: outer.function.as_ref().and_then(demangled_function), + file: outer + .location + .as_ref() + .and_then(|l| l.file.map(str::to_string)), + line: outer.location.as_ref().and_then(|l| l.line), + }); + } + + Ok(frame) + } +} + +fn load(path: &Path) -> Result<Loader> { + let resolved = resolve_dsym(path); + Loader::new(&resolved).map_err(|e| Error::Addr2Line(e.to_string())) +} + +#[derive(Debug, Clone)] +pub struct BinaryInfo { + /// Preferred VM address of the `__text` section (executable code). + pub text_start: FilePc, + pub text_end: FilePc, + /// Preferred VM address of the `__TEXT` segment (Mach-O header). + /// Used to convert kdebug-reported load addresses into a slide. + pub segment_text_start: FilePc, + /// Sorted ascending. + /// File addresses of function symbols in `__text`. + pub function_addrs: Vec<FilePc>, + /// The binary's `LC_UUID`. + pub uuid: Option<[u8; 16]>, +} + +impl BinaryInfo { + /// Parse a Mach-O binary for the data needed to detect ASLR slide. + pub fn open(binary: &Path) -> Result<Self> { + let data = std::fs::read(binary)?; + let file = object::File::parse(&*data)?; + let mut segment_text_start: Option<u64> = None; + let mut segment_text_end: u64 = 0; + for seg in file.segments() { + if let Some(name) = seg.name()? + && name == "__TEXT" + { + segment_text_start = Some(seg.address()); + segment_text_end = seg.address() + seg.size(); + } + } + let (text_start_raw, text_end_raw) = match file.section_by_name("__text") { + Some(sec) => (sec.address(), sec.address() + sec.size()), + None => (segment_text_start.unwrap_or(0), segment_text_end), + }; + if text_end_raw <= text_start_raw { + return Err(Error::Schema("binary has no __TEXT segment".into())); + } + let mut function_addrs: Vec<FilePc> = file + .symbols() + .filter(|s| s.kind() == SymbolKind::Text) + .map(|s| s.address()) + .filter(|a| *a >= text_start_raw && *a < text_end_raw) + .map(FilePc::new) + .collect(); + function_addrs.sort(); + function_addrs.dedup(); + let uuid = file.mach_uuid()?; + let segment_text_start = FilePc::new(segment_text_start.unwrap_or(text_start_raw)); + Ok(BinaryInfo { + text_start: FilePc::new(text_start_raw), + text_end: FilePc::new(text_end_raw), + segment_text_start, + function_addrs, + uuid, + }) + } + + /// Look up the slide by matching this binary's `LC_UUID` against the + /// trace's recorded image loads. + /// Returns `None` if the UUID isn't in `loads` or the binary has no UUID. + pub fn slide_from(&self, loads: &[ImageLoad]) -> Option<Slide> { + let uuid = self.uuid?; + let load = loads.iter().find(|l| l.uuid == uuid)?; + load.load_address + .raw() + .checked_sub(self.segment_text_start.raw()) + .map(Slide::new) + } + + /// Heuristic enumeration of plausible page-aligned ASLR slides. + /// Provided as a fallback for traces with no kdebug DBG\_DYLD events. + /// Ranking is heuristic; multiple slides will look equally valid for short + /// traces or stripped binaries. + pub fn enumerate_slides( + &self, + pcs_with_weight: &[PcSample], + dwarf_path: &Path, + ) -> Vec<SlideCandidate> { + const PAGE: u64 = 0x4000; + let resolved = resolve_dsym(dwarf_path); + let loader = Loader::new(&resolved).ok(); + let text_start = self.text_start.raw(); + let text_end = self.text_end.raw(); + + // Ordered, because the ranking below can leave two candidates tied and + // the order they were enumerated in then decides which one a caller + // acts on. A hashed set would make that choice differently per process, + // and a wrong slide does not fail — it names the wrong functions. + let mut candidates: BTreeSet<u64> = BTreeSet::new(); + for s in pcs_with_weight { + let pc = s.pc.raw(); + if pc < text_start { + continue; + } + let max_slide = pc.saturating_sub(text_start); + let min_slide = pc.saturating_sub(text_end.saturating_sub(1)); + let max_aligned = max_slide & !(PAGE - 1); + let min_aligned = (min_slide + PAGE - 1) & !(PAGE - 1); + let mut s = min_aligned; + while s <= max_aligned { + candidates.insert(s); + s = match s.checked_add(PAGE) { + Some(v) => v, + None => break, + }; + } + } + + let func_addrs_raw: Vec<u64> = self.function_addrs.iter().map(|f| f.raw()).collect(); + let mut out: Vec<SlideCandidate> = Vec::new(); + for slide in candidates { + let mut per_func: HashMap<u64, u64> = HashMap::new(); + let mut covered: u64 = 0; + for s in pcs_with_weight { + let Some(probe) = s.pc.raw().checked_sub(slide) else { + continue; + }; + let Some(func_start) = function_containing(&func_addrs_raw, probe, text_end) else { + continue; + }; + *per_func.entry(func_start).or_insert(0) += s.samples; + covered += s.samples; + } + if covered == 0 { + continue; + } + let mut entries: Vec<(u64, u64)> = per_func.into_iter().collect(); + // Address ascending on a tie: these come out of a `HashMap`, so + // without it two functions with the same sample count would be + // reported interchangeably between runs. + entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + let (top_addr, top_share) = entries[0]; + let func_len = function_length(&func_addrs_raw, top_addr, text_end); + let top_function_name = loader.as_ref().and_then(|l| top_function_at(l, top_addr)); + out.push(SlideCandidate { + slide: Slide::new(slide), + covered_samples: covered, + top_function_samples: top_share, + top_function_address: FilePc::new(top_addr), + top_function_size: func_len, + top_function_name, + }); + } + out.sort_by(rank_candidates); + out + } +} + +/// Rank two slide candidates: most concentrated first, then tightest function, +/// then lowest slide. +/// +/// The slide is the last key, and it is what makes the ranking a total order. +/// Two candidates tied on both heuristics is the normal case for a short trace +/// or a stripped binary — which [`BinaryInfo::enumerate_slides`] says itself +/// — and the candidates are accumulated in a set, so without it the one a +/// caller takes first would differ between runs. +/// A wrong slide does not fail: it names the wrong functions. +fn rank_candidates(a: &SlideCandidate, b: &SlideCandidate) -> Ordering { + b.top_function_samples + .cmp(&a.top_function_samples) + .then_with(|| a.top_function_size.cmp(&b.top_function_size)) + .then_with(|| a.slide.raw().cmp(&b.slide.raw())) +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct ImageLoad { + pub uuid: [u8; 16], + pub load_address: RuntimePc, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SlideCandidate { + pub slide: Slide, + pub covered_samples: u64, + pub top_function_samples: u64, + pub top_function_address: FilePc, + pub top_function_size: u64, + pub top_function_name: Option<String>, +} + +impl TraceBundle { + /// Read every `DBG_DYLD_UUID_MAP_A` event from the trace and decode the + /// (UUID, runtime load address) pairs. + /// These events are kernel ground truth - when dyld maps an image, the + /// kernel records the runtime base address it chose under ASLR. + pub fn image_loads(&self) -> Result<Vec<ImageLoad>> { + let xml = self.xctrace().export_xpath(self.path(), DBG_DYLD_XPATH)?; + let mut reader = RowReader::new(std::io::Cursor::new(xml)); + let mut out: Vec<ImageLoad> = Vec::new(); + while let Some(ev) = reader.next_event()? { + let RowReaderEvent::Row(cells) = ev else { + continue; + }; + let Some(decoded) = decode_dyld_map_a(&cells) else { + continue; + }; + out.push(decoded); + } + Ok(out) + } +} + +const DBG_DYLD_XPATH: &str = "/trace-toc/run[@number=\"1\"]/data/table[@schema=\"kdebug\"]"; + +fn decode_dyld_map_a(cells: &[std::rc::Rc<Cell>]) -> Option<ImageLoad> { + let mut class: Option<u64> = None; + let mut subclass: Option<u64> = None; + let mut code: Option<u64> = None; + let mut args: Vec<u64> = Vec::new(); + for cell in cells { + let Some(name) = cell.element() else { continue }; + match name { + "kdebug-class" => class = cell.as_u64(), + "kdebug-subclass" => subclass = cell.as_u64(), + "kdebug-code" => code = cell.as_u64(), + "kdebug-arg" => { + if let Some(v) = cell.as_u64() { + args.push(v); + } + } + _ => {} + } + } + if class? != 31 || subclass? != 5 || code? != 0 { + return None; + } + if args.len() < 3 { + return None; + } + let mut uuid = [0u8; 16]; + uuid[0..8].copy_from_slice(&args[0].to_le_bytes()); + uuid[8..16].copy_from_slice(&args[1].to_le_bytes()); + Some(ImageLoad { + uuid, + load_address: RuntimePc::new(args[2]), + }) +} + +fn top_function_at(loader: &Loader, probe: u64) -> Option<String> { + let mut iter = loader.find_frames(probe).ok()?; + let mut last: Option<String> = None; + while let Ok(Some(f)) = iter.next() { + if let Some(name) = f.function.as_ref().and_then(|n| n.raw_name().ok()) { + last = Some(demangle(&name)); + } + } + last +} + +fn function_containing(starts: &[u64], probe: u64, text_end: u64) -> Option<u64> { + let i = starts.partition_point(|&s| s <= probe); + if i == 0 { + return None; + } + let func_start = starts[i - 1]; + let func_end = starts.get(i).copied().unwrap_or(text_end); + if probe >= func_start && probe < func_end { + Some(func_start) + } else { + None + } +} + +fn function_length(starts: &[u64], func_start: u64, text_end: u64) -> u64 { + let i = starts.partition_point(|&s| s <= func_start); + let func_end = starts.get(i).copied().unwrap_or(text_end); + func_end.saturating_sub(func_start) +} + +fn resolve_dsym(path: &Path) -> PathBuf { + if path.extension().and_then(|s| s.to_str()) != Some("dSYM") { + return path.to_path_buf(); + } + let dwarf_dir = path.join("Contents").join("Resources").join("DWARF"); + let Ok(entries) = std::fs::read_dir(&dwarf_dir) else { + return path.to_path_buf(); + }; + let mut candidates: Vec<PathBuf> = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file()) + .collect(); + if candidates.is_empty() { + return path.to_path_buf(); + } + candidates.sort_by_key(|p| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)); + candidates.pop().unwrap() +} + +fn demangle(s: &str) -> String { + if let Some(sym) = swift::demangle(s) { + return sym; + } + if let Ok(sym) = rustc_demangle_try(s) { + return sym; + } + if let Ok(sym) = cpp_demangle_try(s) { + return sym; + } + s.to_string() +} + +fn demangled_function<R>(f: &addr2line::FunctionName<R>) -> Option<String> +where + R: gimli::Reader, +{ + let raw = f.raw_name().ok()?; + Some(demangle(&raw)) +} + +fn rustc_demangle_try(s: &str) -> std::result::Result<String, ()> { + Ok(format!( + "{:#}", + rustc_demangle::try_demangle(s).map_err(|_| ())? + )) +} + +fn cpp_demangle_try(s: &str) -> std::result::Result<String, ()> { + let sym = cpp_demangle::Symbol::new(s).map_err(|_| ())?; + sym.demangle().map_err(|_| ()) +} + +#[cfg(test)] +#[path = "macho_tests.rs"] +mod tests; diff --git a/crates/contrib/xct2cli/src/symbol/macho_tests.rs b/crates/contrib/xct2cli/src/symbol/macho_tests.rs new file mode 100644 index 000000000..cc2e928be --- /dev/null +++ b/crates/contrib/xct2cli/src/symbol/macho_tests.rs @@ -0,0 +1,130 @@ +use super::{ + FilePc, Slide, SlideCandidate, demangle, function_containing, function_length, rank_candidates, +}; + +fn candidate(slide: u64, top_function_samples: u64, top_function_size: u64) -> SlideCandidate { + SlideCandidate { + slide: Slide::new(slide), + covered_samples: top_function_samples, + top_function_samples, + top_function_address: FilePc::new(0x1000), + top_function_size, + top_function_name: None, + } +} + +fn ranked(mut candidates: Vec<SlideCandidate>) -> Vec<u64> { + candidates.sort_by(rank_candidates); + + candidates.into_iter().map(|c| c.slide.raw()).collect() +} + +/// The heuristic: the slide that concentrates the most samples in one function +/// is the likeliest, and among equals the tighter function is the better +/// evidence. +#[test] +fn the_most_concentrated_candidate_ranks_first() { + assert_eq!( + ranked(vec![ + candidate(0x8000, 10, 400), + candidate(0x4000, 90, 400), + candidate(0xc000, 50, 400), + ]), + vec![0x4000, 0xc000, 0x8000] + ); +} + +#[test] +fn a_tie_on_samples_prefers_the_tighter_function() { + assert_eq!( + ranked(vec![ + candidate(0x8000, 90, 4_000), + candidate(0x4000, 90, 40) + ]), + vec![0x4000, 0x8000] + ); +} + +/// `enumerate_slides` says itself that several slides look equally valid for a +/// short trace or a stripped binary, and it accumulates its candidates in a +/// set. +/// Without the slide as a last key, which one a caller takes first would differ +/// between runs — and a wrong slide does not fail, it names the wrong +/// functions. +#[test] +fn candidates_tied_on_every_heuristic_rank_by_slide() { + let one = vec![ + candidate(0xc000, 90, 400), + candidate(0x4000, 90, 400), + candidate(0x8000, 90, 400), + ]; + let other = vec![ + candidate(0x8000, 90, 400), + candidate(0xc000, 90, 400), + candidate(0x4000, 90, 400), + ]; + + assert_eq!(ranked(one.clone()), ranked(other)); + assert_eq!(ranked(one), vec![0x4000, 0x8000, 0xc000]); +} + +/// A probe lands in the function whose range covers it, and the last function's +/// range runs to the end of `__TEXT` because nothing follows it to bound it. +#[test] +fn a_probe_resolves_to_the_function_containing_it() { + let starts = [0x1000, 0x1400, 0x2000]; + + assert_eq!(function_containing(&starts, 0x1000, 0x3000), Some(0x1000)); + assert_eq!(function_containing(&starts, 0x13ff, 0x3000), Some(0x1000)); + assert_eq!(function_containing(&starts, 0x1400, 0x3000), Some(0x1400)); + assert_eq!(function_containing(&starts, 0x2fff, 0x3000), Some(0x2000)); +} + +#[test] +fn a_probe_outside_every_function_resolves_to_none() { + let starts = [0x1000, 0x1400]; + + // Before the first function. + assert_eq!(function_containing(&starts, 0x0fff, 0x2000), None); + + // Past the end of `__TEXT`, which bounds the last function. + assert_eq!(function_containing(&starts, 0x2000, 0x2000), None); + assert_eq!(function_containing(&[], 0x1000, 0x2000), None); +} + +#[test] +fn a_functions_length_runs_to_the_next_start_or_to_the_end_of_text() { + let starts = [0x1000, 0x1400, 0x2000]; + + assert_eq!(function_length(&starts, 0x1000, 0x3000), 0x400); + assert_eq!(function_length(&starts, 0x1400, 0x3000), 0xc00); + assert_eq!(function_length(&starts, 0x2000, 0x3000), 0x1000); +} + +/// A `__TEXT` end below the function start would otherwise underflow. +#[test] +fn a_function_starting_past_the_end_of_text_has_no_length() { + assert_eq!(function_length(&[0x4000], 0x4000, 0x1000), 0); +} + +/// Three manglings reach this, and a name in none of them is its own answer: a +/// symbol nobody can demangle is still the only name that frame has. +#[test] +fn a_name_is_demangled_by_whichever_scheme_claims_it() { + assert_eq!( + demangle("_ZN4core3fmt9Formatter3pad17h0123456789abcdefE"), + "core::fmt::Formatter::pad" + ); + assert_eq!(demangle("main"), "main"); + assert_eq!(demangle(""), ""); +} + +/// Swift is the one scheme whose demangler is loaded out of the installed Xcode +/// rather than linked in, so it is asserted only where there is one. +/// Without a toolchain `demangle` hands back the mangled name, which is the +/// documented degradation rather than a failure. +#[cfg(target_os = "macos")] +#[test] +fn a_swift_name_is_demangled_through_the_toolchain() { + assert_eq!(demangle("_$s2JP5TraceO5eventyyF"), "JP.Trace.event() -> ()"); +} diff --git a/crates/contrib/xct2cli/src/symbol/mod.rs b/crates/contrib/xct2cli/src/symbol/mod.rs new file mode 100644 index 000000000..94e53e842 --- /dev/null +++ b/crates/contrib/xct2cli/src/symbol/mod.rs @@ -0,0 +1,9 @@ +//! Mach-O + DWARF symbolication for instruction-level drilldowns. + +pub mod macho; +pub mod swift; + +pub use macho::{ + BinaryInfo, ImageLoad, InlinedFrame, SlideCandidate, SymbolicatedFrame, Symbolicator, + SymbolicatorOptions, +}; diff --git a/crates/contrib/xct2cli/src/symbol/swift.rs b/crates/contrib/xct2cli/src/symbol/swift.rs new file mode 100644 index 000000000..9977004b1 --- /dev/null +++ b/crates/contrib/xct2cli/src/symbol/swift.rs @@ -0,0 +1,151 @@ +//! Swift symbol demangling through the toolchain's `libswiftDemangle`. +//! +//! Xcode ships `libswiftDemangle.dylib` inside its default toolchain. +//! It is opened on the first Swift-looking symbol and kept for the lifetime of +//! the process. +//! When no copy can be found, [`demangle`] returns `None` and the caller keeps +//! the mangled name. +//! +//! Set `SWIFT_DEMANGLE_DYLIB` to point at a specific copy; otherwise +//! `DEVELOPER_DIR`, `xcode-select -p`, and the Command Line Tools location are +//! tried in that order. +//! +//! Loading needs `dlopen`, so off unix there is nothing to load and every Swift +//! symbol keeps its mangled name. + +#[cfg(unix)] +use std::{env, ffi::c_void, process::Command}; +use std::{ + ffi::{CString, c_char}, + sync::OnceLock, +}; + +#[cfg(unix)] +use libc::{RTLD_LAZY, RTLD_LOCAL, dlopen, dlsym}; + +/// `swift_demangle_getDemangledName`, stable at libswiftDemangle major version +/// 1. +/// Returns the length of the demangled name even when that exceeds the output +/// buffer, or 0 when the input is not a Swift symbol. +type DemangleFn = unsafe extern "C" fn(*const c_char, *mut c_char, usize) -> usize; + +/// Path of the demangler relative to a toolchain root. +#[cfg(unix)] +const RELATIVE_PATH: &str = "Toolchains/XcodeDefault.xctoolchain/usr/lib/libswiftDemangle.dylib"; + +/// Demangled form of `name`, or `None` when it is not a Swift symbol or no +/// demangler could be loaded. +/// +/// A single leading underscore is stripped, so both the Mach-O symbol table +/// spelling (`_$s…`) and the DWARF spelling (`$s…`) are accepted. +pub fn demangle(name: &str) -> Option<String> { + let mangled = name.strip_prefix('_').unwrap_or(name); + if !is_mangled(mangled) { + return None; + } + + let demangle_fn = demangler()?; + let input = CString::new(mangled).ok()?; + + // Generic specializations run long, but the overwhelming majority of + // symbols fit the first buffer and never pay for the second call. + let mut buf = vec![0u8; 512]; + let mut needed = unsafe { demangle_fn(input.as_ptr(), buf.as_mut_ptr().cast(), buf.len()) }; + if needed == 0 { + return None; + } + if needed >= buf.len() { + buf = vec![0u8; needed + 1]; + needed = unsafe { demangle_fn(input.as_ptr(), buf.as_mut_ptr().cast(), buf.len()) }; + if needed == 0 || needed >= buf.len() { + return None; + } + } + + buf.truncate(needed); + String::from_utf8(buf).ok() +} + +/// Whether `name` carries a Swift mangling prefix. +/// +/// Covers Swift 4.0 onwards, which is every symbol a current toolchain emits. +/// A false positive costs one rejected call into the demangler, so the check +/// errs towards being narrow. +pub fn is_mangled(name: &str) -> bool { + let name = name.strip_prefix('_').unwrap_or(name); + name.starts_with("$s") || name.starts_with("$S") || name.starts_with("T0") +} + +/// The loaded demangler, resolved once per process. +fn demangler() -> Option<DemangleFn> { + static DEMANGLER: OnceLock<Option<DemangleFn>> = OnceLock::new(); + *DEMANGLER.get_or_init(load) +} + +#[cfg(not(unix))] +fn load() -> Option<DemangleFn> { + None +} + +#[cfg(unix)] +fn load() -> Option<DemangleFn> { + for path in candidate_paths() { + let Ok(path) = CString::new(path) else { + continue; + }; + + // SAFETY: `path` is a valid NUL-terminated C string. The handle is + // deliberately leaked; the function pointer read out of it is cached + // for the lifetime of the process, so closing would dangle it. + let handle = unsafe { dlopen(path.as_ptr(), RTLD_LAZY | RTLD_LOCAL) }; + if handle.is_null() { + continue; + } + + // SAFETY: `handle` came back non-null from `dlopen`, and the symbol + // name is a literal NUL-terminated C string. + let symbol = unsafe { dlsym(handle, c"swift_demangle_getDemangledName".as_ptr()) }; + if symbol.is_null() { + continue; + } + + // SAFETY: the symbol is libswiftDemangle's documented C entry point + // and matches `DemangleFn`. A data pointer and a function pointer are + // the same width on every platform this crate runs on. + return Some(unsafe { std::mem::transmute::<*mut c_void, DemangleFn>(symbol) }); + } + None +} + +/// Places to look for the demangler, most specific first. +#[cfg(unix)] +fn candidate_paths() -> Vec<String> { + let mut paths = Vec::new(); + if let Ok(explicit) = env::var("SWIFT_DEMANGLE_DYLIB") { + paths.push(explicit); + } + if let Ok(dir) = env::var("DEVELOPER_DIR") { + paths.push(format!("{dir}/{RELATIVE_PATH}")); + } + if let Some(dir) = developer_dir() { + paths.push(format!("{dir}/{RELATIVE_PATH}")); + } + paths.push("/Library/Developer/CommandLineTools/usr/lib/libswiftDemangle.dylib".to_owned()); + paths +} + +/// Active developer directory, per `xcode-select -p`. +#[cfg(unix)] +fn developer_dir() -> Option<String> { + let output = Command::new("xcode-select").arg("-p").output().ok()?; + if !output.status.success() { + return None; + } + let dir = String::from_utf8(output.stdout).ok()?; + let dir = dir.trim(); + (!dir.is_empty()).then(|| dir.to_owned()) +} + +#[cfg(test)] +#[path = "swift_tests.rs"] +mod tests; diff --git a/crates/contrib/xct2cli/src/symbol/swift_tests.rs b/crates/contrib/xct2cli/src/symbol/swift_tests.rs new file mode 100644 index 000000000..ed74934ca --- /dev/null +++ b/crates/contrib/xct2cli/src/symbol/swift_tests.rs @@ -0,0 +1,45 @@ +use super::{demangle, is_mangled}; + +#[test] +fn recognises_swift_manglings() { + assert!(is_mangled("$s2JP17ConversationEventV4bodyQrvg")); + assert!(is_mangled("_$s2JP17ConversationEventV4bodyQrvg")); + assert!(is_mangled("$S3foo3barC")); + assert!(is_mangled("_T0Si")); +} + +#[test] +fn ignores_other_manglings() { + assert!(!is_mangled("_ZN3std2io4Read11read_to_endE")); + assert!(!is_mangled("_RNvCskwGfYPst2Cb_3foo3bar")); + assert!(!is_mangled("-[NSView drawRect:]")); + assert!(!is_mangled("main")); + assert!(!is_mangled("")); +} + +/// The demangler is loaded out of the installed Xcode, so this asserts against +/// a real toolchain rather than a vendored copy. +/// A missing dylib fails the test instead of skipping it: on a machine that can +/// build the macOS app, the dylib is always there, and a silent skip would let +/// the whole feature rot. +#[cfg(target_os = "macos")] +#[test] +fn demangles_through_the_toolchain() { + assert_eq!( + demangle("$s4main5helloSSyYaKF").as_deref(), + Some("main.hello() async throws -> Swift.String") + ); + + // The shape a Time Profiler row reports for one of our own accessors, + // with the leading underscore the Mach-O symbol table carries. + assert_eq!( + demangle("_$s2JP17ConversationEventV4bodyQrvg").as_deref(), + Some("JP.ConversationEvent.body.getter : some") + ); +} + +#[test] +fn passes_through_non_swift_symbols() { + assert_eq!(demangle("_ZN3std2io4Read11read_to_endE"), None); + assert_eq!(demangle("main"), None); +} diff --git a/crates/contrib/xct2cli/src/trace/mod.rs b/crates/contrib/xct2cli/src/trace/mod.rs new file mode 100644 index 000000000..af7dd926d --- /dev/null +++ b/crates/contrib/xct2cli/src/trace/mod.rs @@ -0,0 +1,115 @@ +//! High-level facade over a `.trace` bundle. + +pub mod toc; + +use std::{ + path::{Path, PathBuf}, + rc::Rc, +}; + +use serde::Serialize; +pub use toc::{Table, Toc}; + +use crate::{ + error::{Error, Result}, + xctrace::Xctrace, + xml::{ + Cell, Schema, + stream::{RowReader, RowReaderEvent}, + }, +}; + +/// On-disk Instruments `.trace` bundle. +/// Cheap to construct; no I/O is performed until you call a method. +#[derive(Debug, Clone)] +pub struct TraceBundle { + path: PathBuf, + xctrace: Xctrace, +} + +impl TraceBundle { + pub fn open(path: impl Into<PathBuf>) -> Result<Self> { + let path = path.into(); + if !path.exists() { + return Err(Error::BundleMissing(path)); + } + Ok(Self { + path, + xctrace: Xctrace::discover(), + }) + } + + pub fn with_xctrace(mut self, xctrace: Xctrace) -> Self { + self.xctrace = xctrace; + self + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn xctrace(&self) -> &Xctrace { + &self.xctrace + } + + pub fn toc(&self) -> Result<Toc> { + let xml = self.xctrace.export_toc(&self.path)?; + Toc::parse(&xml) + } + + /// Run an XPath query and return parsed rows for every `<node>`. + pub fn query(&self, xpath: &str) -> Result<QueryResult> { + let xml = self.xctrace.export_xpath(&self.path, xpath)?; + QueryResult::parse(xml) + } +} + +/// In-memory snapshot of a single XPath query. +#[derive(Debug, Default, Serialize)] +pub struct QueryResult { + pub nodes: Vec<NodeData>, +} + +#[derive(Debug, Serialize)] +pub struct NodeData { + pub xpath: Option<String>, + pub schema: Option<Schema>, + pub rows: Vec<Vec<Rc<Cell>>>, +} + +impl QueryResult { + pub fn parse(xml: Vec<u8>) -> Result<Self> { + let cursor = std::io::Cursor::new(xml); + let mut reader = RowReader::new(cursor); + let mut result = QueryResult::default(); + let mut current: Option<NodeData> = None; + while let Some(ev) = reader.next_event()? { + match ev { + RowReaderEvent::NodeStart(node) => { + if let Some(prev) = current.take() { + result.nodes.push(prev); + } + current = Some(NodeData { + xpath: node.xpath, + schema: node.schema, + rows: Vec::new(), + }); + } + RowReaderEvent::Row(cells) => { + if let Some(n) = current.as_mut() { + n.rows.push(cells); + } + } + RowReaderEvent::NodeEnd => { + if let Some(n) = current.take() { + result.nodes.push(n); + } + } + } + } + if let Some(n) = current.take() { + result.nodes.push(n); + } + Ok(result) + } +} diff --git a/crates/contrib/xct2cli/src/trace/toc.rs b/crates/contrib/xct2cli/src/trace/toc.rs new file mode 100644 index 000000000..4d466e55a --- /dev/null +++ b/crates/contrib/xct2cli/src/trace/toc.rs @@ -0,0 +1,336 @@ +use std::{collections::BTreeMap, io::Cursor}; + +use quick_xml::{ + Reader, + events::{BytesStart, Event}, +}; +use serde::Serialize; + +use crate::{ + error::{Error, Result}, + xml::XML_VERSION, +}; + +#[derive(Debug, Clone, Serialize)] +pub struct Toc { + pub runs: Vec<Run>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Run { + pub number: u32, + pub info: Info, + pub processes: Vec<TocProcess>, + pub tables: Vec<Table>, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct Info { + pub target: Option<Target>, + pub summary: Option<Summary>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Target { + pub device: BTreeMap<String, String>, + pub process: BTreeMap<String, String>, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct Summary { + pub start_date: Option<String>, + pub end_date: Option<String>, + pub duration: Option<String>, + pub end_reason: Option<String>, + pub instruments_version: Option<String>, + pub template_name: Option<String>, + pub recording_mode: Option<String>, + pub time_limit: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TocProcess { + pub name: String, + pub pid: i64, + pub path: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Table { + pub schema: String, + pub documentation: Option<String>, + pub attributes: BTreeMap<String, String>, +} + +impl Toc { + pub fn parse(xml: &[u8]) -> Result<Self> { + let mut reader = Reader::from_reader(Cursor::new(xml)); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + let mut runs: Vec<Run> = Vec::new(); + loop { + buf.clear(); + match reader.read_event_into(&mut buf)? { + Event::Eof => break, + Event::Start(s) if local_name(&s)? == "trace-toc" => continue, + Event::Start(s) if local_name(&s)? == "run" => { + let number = u32_attr(&s, b"number")?.unwrap_or(0); + runs.push(read_run(&mut reader, number)?); + } + _ => {} + } + } + Ok(Toc { runs }) + } + + pub fn run(&self, number: u32) -> Option<&Run> { + self.runs.iter().find(|r| r.number == number) + } + + pub fn first_run(&self) -> Option<&Run> { + self.runs.first() + } +} + +impl Run { + pub fn table(&self, schema: &str) -> Option<&Table> { + self.tables.iter().find(|t| t.schema == schema) + } + + pub fn tables_with(&self, schema: &str) -> impl Iterator<Item = &Table> { + self.tables.iter().filter(move |t| t.schema == schema) + } +} + +fn read_run<R: std::io::BufRead>(reader: &mut Reader<R>, number: u32) -> Result<Run> { + let mut buf = Vec::new(); + let mut info = Info::default(); + let mut processes: Vec<TocProcess> = Vec::new(); + let mut tables: Vec<Table> = Vec::new(); + loop { + buf.clear(); + match reader.read_event_into(&mut buf)? { + Event::Start(s) => match local_name(&s)?.as_str() { + "info" => info = read_info(reader)?, + "processes" => processes = read_processes(reader)?, + "data" => tables = read_data(reader)?, + "tracks" => skip_to_end(reader, "tracks")?, + _ => skip_to_end(reader, &local_name(&s)?)?, + }, + Event::Empty(_) => {} + Event::End(e) if std::str::from_utf8(e.name().as_ref())? == "run" => break, + Event::Eof => return Err(Error::Schema("EOF inside <run>".into())), + _ => {} + } + } + Ok(Run { + number, + info, + processes, + tables, + }) +} + +fn read_info<R: std::io::BufRead>(reader: &mut Reader<R>) -> Result<Info> { + let mut buf = Vec::new(); + let mut info = Info::default(); + loop { + buf.clear(); + match reader.read_event_into(&mut buf)? { + Event::Start(s) => match local_name(&s)?.as_str() { + "target" => info.target = Some(read_target(reader)?), + "summary" => info.summary = Some(read_summary(reader)?), + other => skip_to_end(reader, other)?, + }, + Event::Empty(_) => {} + Event::End(e) if std::str::from_utf8(e.name().as_ref())? == "info" => break, + Event::Eof => return Err(Error::Schema("EOF inside <info>".into())), + _ => {} + } + } + Ok(info) +} + +fn read_target<R: std::io::BufRead>(reader: &mut Reader<R>) -> Result<Target> { + let mut buf = Vec::new(); + let mut device = BTreeMap::new(); + let mut process = BTreeMap::new(); + loop { + buf.clear(); + match reader.read_event_into(&mut buf)? { + Event::Empty(s) => match local_name(&s)?.as_str() { + "device" => device = collect_attrs(&s)?, + "process" => process = collect_attrs(&s)?, + _ => {} + }, + Event::Start(s) => { + let n = local_name(&s)?; + let attrs = collect_attrs(&s)?; + match n.as_str() { + "device" => device = attrs, + "process" => process = attrs, + _ => {} + } + skip_to_end(reader, &n)?; + } + Event::End(e) if std::str::from_utf8(e.name().as_ref())? == "target" => break, + Event::Eof => return Err(Error::Schema("EOF inside <target>".into())), + _ => {} + } + } + Ok(Target { device, process }) +} + +fn read_summary<R: std::io::BufRead>(reader: &mut Reader<R>) -> Result<Summary> { + let mut buf = Vec::new(); + let mut summary = Summary::default(); + let mut current: Option<String> = None; + let mut text_buf = String::new(); + loop { + buf.clear(); + match reader.read_event_into(&mut buf)? { + Event::Start(s) => { + let name = local_name(&s)?; + if matches!( + name.as_str(), + "intruments-recording-settings" | "instruments-recording-settings" + ) { + skip_to_end(reader, &name)?; + continue; + } + current = Some(name); + text_buf.clear(); + } + Event::Text(t) => { + if current.is_some() { + text_buf.push_str(&t.xml_content(XML_VERSION)?); + } + } + Event::End(e) => { + let n = std::str::from_utf8(e.name().as_ref())?.to_string(); + if n == "summary" { + break; + } + if let Some(opening) = current.take() + && opening == n + { + let v = std::mem::take(&mut text_buf).trim().to_string(); + match n.as_str() { + "start-date" => summary.start_date = Some(v), + "end-date" => summary.end_date = Some(v), + "duration" => summary.duration = Some(v), + "end-reason" => summary.end_reason = Some(v), + "instruments-version" => summary.instruments_version = Some(v), + "template-name" => summary.template_name = Some(v), + "recording-mode" => summary.recording_mode = Some(v), + "time-limit" => summary.time_limit = Some(v), + _ => {} + } + } + } + Event::Eof => return Err(Error::Schema("EOF inside <summary>".into())), + _ => {} + } + } + Ok(summary) +} + +fn read_processes<R: std::io::BufRead>(reader: &mut Reader<R>) -> Result<Vec<TocProcess>> { + let mut buf = Vec::new(); + let mut out: Vec<TocProcess> = Vec::new(); + loop { + buf.clear(); + match reader.read_event_into(&mut buf)? { + Event::Empty(s) | Event::Start(s) if local_name(&s)? == "process" => { + let attrs = collect_attrs(&s)?; + let name = attrs.get("name").cloned().unwrap_or_default(); + let pid = attrs.get("pid").and_then(|v| v.parse().ok()).unwrap_or(-1); + let path = attrs.get("path").cloned(); + out.push(TocProcess { name, pid, path }); + if matches!(reader.read_event_into(&mut Vec::new())?, Event::End(_)) {} + } + Event::End(e) if std::str::from_utf8(e.name().as_ref())? == "processes" => break, + Event::Eof => return Err(Error::Schema("EOF inside <processes>".into())), + _ => {} + } + } + Ok(out) +} + +fn read_data<R: std::io::BufRead>(reader: &mut Reader<R>) -> Result<Vec<Table>> { + let mut buf = Vec::new(); + let mut tables: Vec<Table> = Vec::new(); + loop { + buf.clear(); + match reader.read_event_into(&mut buf)? { + Event::Empty(s) if local_name(&s)? == "table" => { + tables.push(table_from_attrs(&s)?); + } + Event::Start(s) if local_name(&s)? == "table" => { + let t = table_from_attrs(&s)?; + tables.push(t); + skip_to_end(reader, "table")?; + } + Event::End(e) if std::str::from_utf8(e.name().as_ref())? == "data" => break, + Event::Eof => return Err(Error::Schema("EOF inside <data>".into())), + _ => {} + } + } + Ok(tables) +} + +fn table_from_attrs(s: &BytesStart<'_>) -> Result<Table> { + let mut attrs = collect_attrs(s)?; + let schema = attrs + .remove("schema") + .ok_or_else(|| Error::Schema("table missing schema attr".into()))?; + let documentation = attrs.remove("documentation"); + Ok(Table { + schema, + documentation, + attributes: attrs, + }) +} + +fn collect_attrs(s: &BytesStart<'_>) -> Result<BTreeMap<String, String>> { + let mut out = BTreeMap::new(); + for attr in s.attributes() { + let attr = attr.map_err(quick_xml::Error::from)?; + let key = std::str::from_utf8(attr.key.as_ref())?.to_string(); + let val = attr.normalized_value(XML_VERSION)?.into_owned(); + out.insert(key, val); + } + Ok(out) +} + +fn local_name(s: &BytesStart<'_>) -> Result<String> { + Ok(std::str::from_utf8(s.local_name().as_ref())?.to_string()) +} + +fn u32_attr(s: &BytesStart<'_>, key: &[u8]) -> Result<Option<u32>> { + for attr in s.attributes() { + let attr = attr.map_err(quick_xml::Error::from)?; + if attr.key.as_ref() == key { + let v = attr.normalized_value(XML_VERSION)?; + return Ok(Some(v.parse()?)); + } + } + Ok(None) +} + +fn skip_to_end<R: std::io::BufRead>(reader: &mut Reader<R>, name: &str) -> Result<()> { + let mut buf = Vec::new(); + let mut depth: i32 = 1; + while depth > 0 { + buf.clear(); + match reader.read_event_into(&mut buf)? { + Event::Start(s) if local_name(&s)? == name => depth += 1, + Event::End(e) if std::str::from_utf8(e.name().as_ref())? == name => depth -= 1, + Event::Eof => { + return Err(Error::Schema(format!("EOF while skipping <{name}>"))); + } + _ => {} + } + } + Ok(()) +} diff --git a/crates/contrib/xct2cli/src/xctrace.rs b/crates/contrib/xct2cli/src/xctrace.rs new file mode 100644 index 000000000..7000faaf4 --- /dev/null +++ b/crates/contrib/xct2cli/src/xctrace.rs @@ -0,0 +1,127 @@ +//! Subprocess wrapper around the `xctrace` CLI. + +use std::{ + ffi::{OsStr, OsString}, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use camino::Utf8Path; + +use crate::error::{Error, Result}; + +pub const DEFAULT_XCTRACE: &str = "/usr/bin/xctrace"; + +#[derive(Debug, Clone)] +pub struct Xctrace { + binary: PathBuf, +} + +impl Default for Xctrace { + fn default() -> Self { + Self { + binary: PathBuf::from(DEFAULT_XCTRACE), + } + } +} + +impl Xctrace { + pub fn at(path: impl Into<PathBuf>) -> Self { + Self { + binary: path.into(), + } + } + + /// Resolve via `$XCTRACE_BIN`, falling back to `/usr/bin/xctrace`. + pub fn discover() -> Self { + if let Ok(env_path) = std::env::var("XCTRACE_BIN") { + return Self::at(env_path); + } + Self::default() + } + + /// Crate-private: raw XML carries the recorded process's environment, so it + /// must not leave without passing through a parser that models only the + /// fields this crate understands. + /// Callers get [`crate::trace::Toc`]. + pub(crate) fn export_toc(&self, trace: &Path) -> Result<Vec<u8>> { + self.run("export", &[ + OsStr::new("--input"), + trace.as_os_str(), + OsStr::new("--toc"), + ]) + } + + /// Crate-private for the same reason as [`Self::export_toc`]. + /// Callers get [`crate::trace::QueryResult`]. + pub(crate) fn export_xpath(&self, trace: &Path, xpath: &str) -> Result<Vec<u8>> { + self.run("export", &[ + OsStr::new("--input"), + trace.as_os_str(), + OsStr::new("--xpath"), + OsStr::new(xpath), + ]) + } + + pub fn record_launch( + &self, + template: &str, + output_trace: &Utf8Path, + target: &Path, + target_args: &[OsString], + extra_env: &[(String, String)], + ) -> Result<()> { + let mut args: Vec<OsString> = vec![ + "record".into(), + "--template".into(), + template.into(), + "--output".into(), + output_trace.as_os_str().into(), + "--no-prompt".into(), + ]; + for (k, v) in extra_env { + args.push("--env".into()); + args.push(format!("{k}={v}").into()); + } + args.push("--launch".into()); + args.push("--".into()); + args.push(target.as_os_str().into()); + for a in target_args { + args.push(a.clone()); + } + let _ = self.run_args("record", &args)?; + Ok(()) + } + + fn run(&self, sub: &'static str, tail: &[&OsStr]) -> Result<Vec<u8>> { + let mut args: Vec<OsString> = Vec::with_capacity(tail.len() + 1); + args.push(sub.into()); + for t in tail { + args.push((*t).to_owned()); + } + self.run_args(sub, &args) + } + + fn run_args(&self, sub: &'static str, args: &[OsString]) -> Result<Vec<u8>> { + let mut cmd = Command::new(&self.binary); + cmd.args(args); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + tracing::debug!(?args, binary = ?self.binary, "spawning xctrace"); + let out = cmd.output().map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => Error::XctraceMissing(self.binary.clone()), + _ => Error::Io(e), + })?; + if !out.status.success() { + return Err(Error::XctraceFailed { + subcommand: sub, + status: out.status, + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }); + } + // Every byte xctrace produces leaves through here, so this is the one + // place that can guarantee the recorded process's environment never + // reaches a caller. + Ok(crate::redact::strip_environment(out.stdout)) + } +} diff --git a/crates/contrib/xct2cli/src/xml/mod.rs b/crates/contrib/xct2cli/src/xml/mod.rs new file mode 100644 index 000000000..8ae12dd56 --- /dev/null +++ b/crates/contrib/xct2cli/src/xml/mod.rs @@ -0,0 +1,15 @@ +//! Streaming parser for `xctrace export --xpath` XML results. + +use quick_xml::XmlVersion; + +pub mod schema; +pub mod stream; +pub mod value; + +pub use schema::{Column, EngineeringType, Schema}; +pub use stream::{Node, RowReader, RowReaderEvent}; +pub use value::Cell; + +/// Instruments' exported XML opens with `<?xml version="1.0"?>`. quick-xml +/// needs the declared version to pick its normalization rules. +pub(crate) const XML_VERSION: XmlVersion = XmlVersion::Explicit1_0; diff --git a/crates/contrib/xct2cli/src/xml/schema.rs b/crates/contrib/xct2cli/src/xml/schema.rs new file mode 100644 index 000000000..687283678 --- /dev/null +++ b/crates/contrib/xct2cli/src/xml/schema.rs @@ -0,0 +1,46 @@ +use serde::Serialize; + +/// Per-table schema as emitted at the top of each `<node>` in a +/// `trace-query-result`. +#[derive(Debug, Clone, Serialize)] +pub struct Schema { + pub name: String, + pub documentation: Option<String>, + pub columns: Vec<Column>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Column { + /// Short identifier (e.g. `time`, `core-index`). + pub mnemonic: String, + /// Human-readable label (e.g. `Timestamp`, `Core Index`). + pub name: String, + /// xctrace's internal type tag (e.g. `sample-time`, `kperf-bt`). + pub engineering_type: EngineeringType, +} + +/// xctrace's internal type tag (e.g. `sample-time`, `kperf-bt`). +/// Open-ended because Apple adds new tags between Xcode releases. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct EngineeringType(String); + +impl EngineeringType { + pub fn new(value: impl Into<String>) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn is(&self, other: &str) -> bool { + self.0 == other + } +} + +impl From<String> for EngineeringType { + fn from(s: String) -> Self { + Self(s) + } +} diff --git a/crates/contrib/xct2cli/src/xml/stream.rs b/crates/contrib/xct2cli/src/xml/stream.rs new file mode 100644 index 000000000..a279f4109 --- /dev/null +++ b/crates/contrib/xct2cli/src/xml/stream.rs @@ -0,0 +1,400 @@ +use std::{ + collections::{HashMap, VecDeque}, + io::BufRead, + rc::Rc, +}; + +use quick_xml::{ + Reader, + events::{BytesStart, Event}, +}; + +use crate::{ + error::{Error, Result}, + xml::{ + XML_VERSION, + schema::{Column, EngineeringType, Schema}, + value::{Cell, CompositeCell, LeafCell}, + }, +}; + +#[derive(Debug)] +pub struct Node { + pub xpath: Option<String>, + pub schema: Option<Schema>, +} + +#[derive(Debug)] +pub enum RowReaderEvent { + NodeStart(Node), + Row(Vec<Rc<Cell>>), + NodeEnd, +} + +/// Result of `read_node_prelude`: optional schema, plus the first row if we had +/// to consume one to know the prelude was over. +type NodePrelude = (Option<Schema>, Option<Vec<Rc<Cell>>>); + +pub struct RowReader<R: BufRead> { + reader: Reader<R>, + buf: Vec<u8>, + ids: HashMap<u64, Rc<Cell>>, + pending: VecDeque<RowReaderEvent>, + seen_root: bool, + in_node: bool, +} + +impl<R: BufRead> RowReader<R> { + pub fn new(reader: R) -> Self { + let mut r = Reader::from_reader(reader); + r.config_mut().trim_text(false); + Self { + reader: r, + buf: Vec::with_capacity(4096), + ids: HashMap::new(), + pending: VecDeque::new(), + seen_root: false, + in_node: false, + } + } + + pub fn next_event(&mut self) -> Result<Option<RowReaderEvent>> { + if let Some(ev) = self.pending.pop_front() { + if matches!(ev, RowReaderEvent::NodeEnd) { + self.in_node = false; + } + return Ok(Some(ev)); + } + loop { + self.buf.clear(); + let ev = self.reader.read_event_into(&mut self.buf)?; + match ev { + Event::Eof => return Ok(None), + Event::Decl(_) | Event::Comment(_) | Event::PI(_) | Event::DocType(_) => continue, + Event::Text(_) | Event::CData(_) => continue, + Event::Start(start) => { + let owned = start.into_owned(); + let name = local_name(&owned)?; + if !self.seen_root { + if name != "trace-query-result" { + return Err(Error::Schema(format!( + "expected <trace-query-result> root, got <{name}>" + ))); + } + self.seen_root = true; + continue; + } + if !self.in_node && name == "node" { + let xpath = attr_string(&owned, b"xpath")?; + let (schema, first_row) = self.read_node_prelude()?; + self.in_node = true; + if let Some(row) = first_row { + self.pending.push_back(RowReaderEvent::Row(row)); + } + return Ok(Some(RowReaderEvent::NodeStart(Node { xpath, schema }))); + } + if self.in_node && name == "row" { + let cells = self.read_row(&name)?; + return Ok(Some(RowReaderEvent::Row(cells))); + } + return Err(Error::Schema(format!( + "unexpected <{name}> at top level (in_node={})", + self.in_node + ))); + } + Event::End(end) => { + let qn = end.name(); + let name = std::str::from_utf8(qn.as_ref())?; + if name == "node" && self.in_node { + self.in_node = false; + return Ok(Some(RowReaderEvent::NodeEnd)); + } + if name == "trace-query-result" { + return Ok(None); + } + } + Event::Empty(start) => { + let owned = start.into_owned(); + let name = local_name(&owned)?; + if self.in_node && name == "row" { + return Ok(Some(RowReaderEvent::Row(Vec::new()))); + } + } + _ => {} + } + } + } + + fn read_node_prelude(&mut self) -> Result<NodePrelude> { + let mut schema: Option<Schema> = None; + loop { + self.buf.clear(); + let ev = self.reader.read_event_into(&mut self.buf)?; + match ev { + Event::Text(_) | Event::CData(_) | Event::Comment(_) | Event::PI(_) => continue, + Event::Start(start) => { + let owned = start.into_owned(); + let name = local_name(&owned)?; + if name == "schema" { + schema = Some(self.read_schema_body(&owned)?); + continue; + } + if name == "row" { + let row = self.read_row(&name)?; + return Ok((schema, Some(row))); + } + return Err(Error::Schema(format!( + "expected <schema> or <row> inside <node>, got <{name}>" + ))); + } + Event::Empty(start) => { + let owned = start.into_owned(); + let name = local_name(&owned)?; + if name == "schema" { + schema = Some(Schema { + name: attr_string(&owned, b"name")? + .ok_or_else(|| Error::Schema("schema missing name".into()))?, + documentation: attr_string(&owned, b"documentation")?, + columns: Vec::new(), + }); + continue; + } + if name == "row" { + return Ok((schema, Some(Vec::new()))); + } + } + Event::End(end) => { + let qn = end.name(); + let n = std::str::from_utf8(qn.as_ref())?; + if n == "node" { + self.pending.push_back(RowReaderEvent::NodeEnd); + } + return Ok((schema, None)); + } + Event::Eof => return Ok((schema, None)), + _ => {} + } + } + } + + fn read_schema_body(&mut self, start: &BytesStart<'_>) -> Result<Schema> { + let name = attr_string(start, b"name")? + .ok_or_else(|| Error::Schema("schema missing name".into()))?; + let documentation = attr_string(start, b"documentation")?; + let mut columns: Vec<Column> = Vec::new(); + loop { + self.buf.clear(); + let ev = self.reader.read_event_into(&mut self.buf)?; + match ev { + Event::Start(s) => { + let owned = s.into_owned(); + if local_name(&owned)? == "col" { + columns.push(self.read_column()?); + } + } + Event::End(e) => { + let qn = e.name(); + if std::str::from_utf8(qn.as_ref())? == "schema" { + break; + } + } + Event::Eof => return Err(Error::Schema("EOF inside <schema>".into())), + _ => {} + } + } + Ok(Schema { + name, + documentation, + columns, + }) + } + + fn read_column(&mut self) -> Result<Column> { + let mut mnemonic = String::new(); + let mut name = String::new(); + let mut engineering_type = String::new(); + let mut current: Option<&'static str> = None; + loop { + self.buf.clear(); + let ev = self.reader.read_event_into(&mut self.buf)?; + match ev { + Event::Start(s) => { + let owned = s.into_owned(); + let n = local_name(&owned)?; + current = match n.as_str() { + "mnemonic" => Some("mnemonic"), + "name" => Some("name"), + "engineering-type" => Some("engineering-type"), + _ => None, + }; + } + Event::Text(t) => { + let txt = t.xml_content(XML_VERSION)?; + match current { + Some("mnemonic") => mnemonic.push_str(&txt), + Some("name") => name.push_str(&txt), + Some("engineering-type") => engineering_type.push_str(&txt), + _ => {} + } + } + Event::End(e) => { + let qn = e.name(); + let n = std::str::from_utf8(qn.as_ref())?; + if n == "col" { + break; + } + current = None; + } + Event::Eof => return Err(Error::Schema("EOF inside <col>".into())), + _ => {} + } + } + Ok(Column { + mnemonic, + name, + engineering_type: EngineeringType::from(engineering_type), + }) + } + + fn read_row(&mut self, row_tag: &str) -> Result<Vec<Rc<Cell>>> { + let mut cells: Vec<Rc<Cell>> = Vec::new(); + loop { + self.buf.clear(); + let ev = self.reader.read_event_into(&mut self.buf)?; + match ev { + Event::Start(s) => { + let owned = s.into_owned(); + cells.push(self.parse_cell_start(&owned)?); + } + Event::Empty(s) => { + let owned = s.into_owned(); + cells.push(self.parse_cell_empty(&owned)?); + } + Event::End(e) => { + let qn = e.name(); + let n = std::str::from_utf8(qn.as_ref())?; + if n == row_tag { + break; + } + } + Event::Eof => return Err(Error::Schema("EOF inside <row>".into())), + _ => {} + } + } + Ok(cells) + } + + fn parse_cell_start(&mut self, start: &BytesStart<'_>) -> Result<Rc<Cell>> { + let name = local_name(start)?; + let id = attr_u64(start, b"id")?; + let fmt = attr_string(start, b"fmt")?; + if let Some(r) = attr_u64(start, b"ref")? { + self.consume_until_end(&name)?; + return self.lookup_ref(r); + } + let mut text = String::new(); + let mut children: Vec<Rc<Cell>> = Vec::new(); + loop { + self.buf.clear(); + let ev = self.reader.read_event_into(&mut self.buf)?; + match ev { + Event::Start(s) => { + let owned = s.into_owned(); + children.push(self.parse_cell_start(&owned)?); + } + Event::Empty(s) => { + let owned = s.into_owned(); + children.push(self.parse_cell_empty(&owned)?); + } + Event::Text(t) => text.push_str(&t.xml_content(XML_VERSION)?), + Event::CData(t) => text.push_str(std::str::from_utf8(&t)?), + Event::End(e) => { + let qn = e.name(); + let n = std::str::from_utf8(qn.as_ref())?; + if n == name { + break; + } + return Err(Error::Schema(format!( + "mismatched close </{n}> while reading <{name}>" + ))); + } + Event::Eof => return Err(Error::Schema(format!("EOF inside <{name}>"))), + _ => {} + } + } + let cell = if children.is_empty() { + Cell::Leaf(LeafCell { + element: name, + id, + fmt, + text, + }) + } else { + Cell::Composite(CompositeCell { + element: name, + id, + fmt, + children, + }) + }; + let rc = Rc::new(cell); + if let Some(i) = id { + self.ids.insert(i, rc.clone()); + } + Ok(rc) + } + + fn parse_cell_empty(&mut self, start: &BytesStart<'_>) -> Result<Rc<Cell>> { + let name = local_name(start)?; + if name == "sentinel" { + return Ok(Rc::new(Cell::Sentinel)); + } + let id = attr_u64(start, b"id")?; + let fmt = attr_string(start, b"fmt")?; + if let Some(r) = attr_u64(start, b"ref")? { + return self.lookup_ref(r); + } + let cell = Cell::Leaf(LeafCell { + element: name, + id, + fmt, + text: String::new(), + }); + let rc = Rc::new(cell); + if let Some(i) = id { + self.ids.insert(i, rc.clone()); + } + Ok(rc) + } + + fn lookup_ref(&self, id: u64) -> Result<Rc<Cell>> { + self.ids.get(&id).cloned().ok_or(Error::UnresolvedRef(id)) + } + + fn consume_until_end(&mut self, _name: &str) -> Result<()> { + Ok(()) + } +} + +fn local_name(start: &BytesStart<'_>) -> Result<String> { + let ln = start.local_name(); + Ok(std::str::from_utf8(ln.as_ref())?.to_string()) +} + +fn attr_string(start: &BytesStart<'_>, key: &[u8]) -> Result<Option<String>> { + for attr in start.attributes() { + let attr = attr.map_err(quick_xml::Error::from)?; + if attr.key.as_ref() == key { + let v = attr.normalized_value(XML_VERSION).map_err(Error::Xml)?; + return Ok(Some(v.into_owned())); + } + } + Ok(None) +} + +fn attr_u64(start: &BytesStart<'_>, key: &[u8]) -> Result<Option<u64>> { + Ok(match attr_string(start, key)? { + Some(s) => Some(s.parse()?), + None => None, + }) +} diff --git a/crates/contrib/xct2cli/src/xml/value.rs b/crates/contrib/xct2cli/src/xml/value.rs new file mode 100644 index 000000000..dc7ec2a1b --- /dev/null +++ b/crates/contrib/xct2cli/src/xml/value.rs @@ -0,0 +1,93 @@ +use std::rc::Rc; + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind")] +pub enum Cell { + /// `<sentinel/>` - schema column present but no value for this row. + Sentinel, + Leaf(LeafCell), + Composite(CompositeCell), +} + +#[derive(Debug, Clone, Serialize)] +pub struct LeafCell { + pub element: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none")] + pub fmt: Option<String>, + pub text: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CompositeCell { + pub element: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none")] + pub fmt: Option<String>, + pub children: Vec<Rc<Cell>>, +} + +impl Cell { + pub fn element(&self) -> Option<&str> { + match self { + Cell::Sentinel => None, + Cell::Leaf(l) => Some(&l.element), + Cell::Composite(c) => Some(&c.element), + } + } + + pub fn id(&self) -> Option<u64> { + match self { + Cell::Sentinel => None, + Cell::Leaf(l) => l.id, + Cell::Composite(c) => c.id, + } + } + + pub fn fmt(&self) -> Option<&str> { + match self { + Cell::Sentinel => None, + Cell::Leaf(l) => l.fmt.as_deref(), + Cell::Composite(c) => c.fmt.as_deref(), + } + } + + pub fn text(&self) -> Option<&str> { + match self { + Cell::Leaf(l) => Some(&l.text), + _ => None, + } + } + + pub fn children(&self) -> &[Rc<Cell>] { + match self { + Cell::Composite(c) => &c.children, + _ => &[], + } + } + + /// First descendant whose element name equals `tag`, including self. + pub fn find(&self, tag: &str) -> Option<&Cell> { + if self.element() == Some(tag) { + return Some(self); + } + for child in self.children() { + if let Some(hit) = child.find(tag) { + return Some(hit); + } + } + None + } + + pub fn as_i64(&self) -> Option<i64> { + self.text()?.trim().parse().ok() + } + + pub fn as_u64(&self) -> Option<u64> { + self.text()?.trim().parse().ok() + } +} diff --git a/crates/contrib/xct2cli/tests/fixtures/sample-toc.xml b/crates/contrib/xct2cli/tests/fixtures/sample-toc.xml new file mode 100644 index 000000000..57d6bad89 --- /dev/null +++ b/crates/contrib/xct2cli/tests/fixtures/sample-toc.xml @@ -0,0 +1,78 @@ +<?xml version="1.0"?> + +<trace-toc> + <run number="1"> + <info> + <target> + <device platform="macOS" model="MacBook Pro" name="caladan" os-version="26.4 (25E246)" uuid="4AE4829A-B793-5ECA-980F-5D836C8BEDC1"/> + <process type="launched" return-exit-status="0" name="profile_compress" pid="83138" termination-reason="exit(0)"/> + </target> + <summary> + <start-date>2026-04-17T09:24:47.723-07:00</start-date> + <end-date>2026-04-17T09:24:48.613-07:00</end-date> + <duration>0.889698</duration> + <end-reason>Target app exited</end-reason> + <instruments-version>16.0 (17E192)</instruments-version> + <template-name>Time Profiler</template-name> + <recording-mode>Deferred</recording-mode> + <time-limit>12 hours</time-limit> + <intruments-recording-settings> + <instrument name="Hangs"> + <array> + <dictionary> + <key name="Reporting Threshold">Include Microhangs (>250ms)</key> + </dictionary> + </array> + </instrument> + </intruments-recording-settings> + </summary> + </info> + <processes> + <process name="profile_compress" pid="83138" path="/Users/lander/dev/acceleration/target/release/examples/profile_compress"/> + <process name="kernel" pid="0" path="/System/Library/Kernels/kernel.release.t8142"/> + </processes> + <data> + <table schema="tick" frequency="10" documentation="Provides modelers with a regular reference time for modeling fixed time-based statistics. The ticks are evenly spaced with a configurable frequency specifying the number of events to generate per second, so a tick schema with a frequency of 10 will generate a row every 100ms."/> + <table schema="life-cycle-period" target-pid="SINGLE" documentation="Identifies where an application is in its lifecycle."/> + <table schema="tick" frequency="1" documentation="Provides modelers with a regular reference time for modeling fixed time-based statistics. The ticks are evenly spaced with a configurable frequency specifying the number of events to generate per second, so a tick schema with a frequency of 10 will generate a row every 100ms."/> + <table schema="device-thermal-state-intervals" documentation="Denotes the current thermal state of the device."/> + <table category="PointsOfInterest" schema="os-log" documentation="Holds a message from the OS's Unified Logging and Tracing component."/> + <table schema="os-signpost" category="PointsOfInterest" dynamic-tracing-enabled-subsystems=""com.apple.neappprivacy"" documentation="Holds a signpost event from the OS's Unified Logging and Tracing component."/> + <table schema="os-signpost" category="InduceCondition" subsystem=""com.apple.ConditionInducer.LowSeverity"" documentation="Holds a signpost event from the OS's Unified Logging and Tracing component."/> + <table enable-priority-inversion-detection="0" target-pid="SINGLE" message-type="Fault" schema="os-log" category=""Hang Risk" "Severe Hang Risk" CFNetwork Contacts CoreML" subsystem=""com.apple.runtime-issues"" documentation="Holds a message from the OS's Unified Logging and Tracing component."/> + <table schema="hang-risks" detect-priority-inversions="0" target-pid="SINGLE"/> + <table hangs-threshold="250" schema="potential-hangs" target-pid="SINGLE"/> + <table target-pid="SINGLE" schema="global-poi-layout" exclude-os-logs="0" documentation="A point of interest that can be laid out on a graph view."/> + <table schema="kdebug-signpost" target-pid="SINGLE" documentation="Marks a point in time denoted by a special type of kdebug trace."/> + <table target-pid="SINGLE" context-switch-sampling="0" high-frequency-sampling="0" schema="time-profile" needs-kernel-callstack="0" record-waiting-threads="0" documentation="When combined with other time-profile samples, creates a statistical picture of where your application is spending its time."/> + <table codes=""33,0x11"" target="SINGLE" schema="kdebug-strings" documentation="Associates numeric arguments with strings in kdebug trace points."/> + <table schema="dyld-library-load" target-pid="SINGLE"/> + <table codes=""0x1f,0x05"" target="SINGLE" schema="kdebug-strings" documentation="Associates numeric arguments with strings in kdebug trace points."/> + <table schema="process-info" documentation="Associates processes with their process names."/> + <table codes=""0x2b,0xdc"" target="SINGLE" schema="kdebug" documentation="Tracing the kernel and some system frameworks."/> + <table codes=""0x07,0x00"" target="SINGLE" schema="kdebug" documentation="Tracing the kernel and some system frameworks."/> + <table exclude-os-logs="0" schema="region-of-interest" target-pid="SINGLE" documentation="Determined by the modelers and represent a focus area that the developer is likely to be intereseted in."/> + <table codes=""0x1,0x25"" target="SINGLE" schema="kdebug" documentation="Tracing the kernel and some system frameworks."/> + <table codes=""0x2d,*"" schema="kdebug" callstack="user" target="SINGLE" documentation="Tracing the kernel and some system frameworks."/> + <table codes=""0x1f,0x7"" target="SINGLE" schema="kdebug" documentation="Tracing the kernel and some system frameworks."/> + <table codes=""0x2b,0x87"" target="SINGLE" schema="kdebug" documentation="Tracing the kernel and some system frameworks."/> + <table codes=""0x31,0xca"" target="SINGLE" schema="kdebug" documentation="Tracing the kernel and some system frameworks."/> + <table codes=""0x2b,0xd8"" target="SINGLE" schema="kdebug" documentation="Tracing the kernel and some system frameworks."/> + <table codes=""0x1,0xa"" schema="kdebug" callstack="user" target="SINGLE" documentation="Tracing the kernel and some system frameworks."/> + <table codes=""0x21,0xa"" schema="kdebug" callstack="user" target="SINGLE" documentation="Tracing the kernel and some system frameworks."/> + <table codes=""46,2"" schema="kdebug" callstack="user" target="SINGLE" documentation="Tracing the kernel and some system frameworks."/> + <table codes=""33,0x11"" target="SINGLE" schema="kdebug" documentation="Tracing the kernel and some system frameworks."/> + <table codes=""0x2b,0x65"" schema="kdebug" documentation="Tracing the kernel and some system frameworks."/> + <table codes=""0x07,0x00" "0x1f,0x05"" target="SINGLE" schema="kdebug" documentation="Tracing the kernel and some system frameworks."/> + <table schema="global-roi-layout" target-pid="SINGLE" documentation="Layout information for graphing regions of interest."/> + <table category="PointsOfInterest" schema="os-log-arg" documentation="Holds a metadata argument from the OS's Unified Logging and Tracing component."/> + <table schema="gcd-perf-event" target-pid="SINGLE" documentation="Find sub-optimal uses of Grand Central Dispatch."/> + <table schema="thread-info" documentation="Associates threads with their owning process."/> + <table schema="runloop-events" target-pid="SINGLE"/> + <table exclude-os-logs="0" schema="roi-metadata" target-pid="SINGLE" documentation="Describes details about a given region of interest."/> + <table sample-rate-micro-seconds="1000" target="SINGLE" schema="time-sample" callstack="user" all-thread-states="NO" documentation="Holds a raw CPU profiling sample."/> + <table category="PointsOfInterest" schema="os-signpost-arg" documentation="Holds a signpost metadata argument from the OS's Unified Logging and Tracing component."/> + </data> + <tracks/> + </run> +</trace-toc> \ No newline at end of file diff --git a/crates/contrib/xct2cli/tests/fixtures/time-sample.xml b/crates/contrib/xct2cli/tests/fixtures/time-sample.xml new file mode 100644 index 000000000..2c3898f5e --- /dev/null +++ b/crates/contrib/xct2cli/tests/fixtures/time-sample.xml @@ -0,0 +1,42 @@ +<?xml version="1.0"?> +<trace-query-result> +<node xpath='//trace-toc[1]/run[1]/data[1]/table[39]'><schema name="time-sample" documentation="Holds a raw CPU profiling sample."><col><mnemonic>time</mnemonic><name>Timestamp</name><engineering-type>sample-time</engineering-type></col><col><mnemonic>thread</mnemonic><name>Thread</name><engineering-type>thread</engineering-type></col><col><mnemonic>core-index</mnemonic><name>Core Index</name><engineering-type>core</engineering-type></col><col><mnemonic>thread-state</mnemonic><name>Thread State</name><engineering-type>thread-state</engineering-type></col><col><mnemonic>cp-kernel-callstack</mnemonic><name>Kernel Callstack ID</name><engineering-type>kperf-bt</engineering-type></col><col><mnemonic>cp-user-callstack</mnemonic><name>User Callstack ID</name><engineering-type>kperf-bt</engineering-type></col><col><mnemonic>sample-type</mnemonic><name>Sample type</name><engineering-type>time-sample-kind</engineering-type></col></schema><row><sample-time id="1" fmt="00:00.267.064">267064416</sample-time><thread id="2" fmt="Main Thread (0x9db2dc) (profile_compress, pid: 83138)"><tid id="3" fmt="0x9db2dc">10334940</tid><process id="4" fmt="profile_compress (83138)"><pid id="5" fmt="83138">83138</pid><device-session id="6" fmt="TODO">TODO</device-session></process></thread><sentinel/><thread-state id="7" fmt="Blocked">Blocked</thread-state><sentinel/><kperf-bt id="8" fmt="PC:0x102fec9c0, 1 frames, 0 regs, pid: 83138"><text-addresses id="9" fmt="frag 2">4345219520</text-addresses><text-address id="10" fmt="0x102fec9c0">4345219520</text-address><process ref="4"/><boolean id="11" fmt="No">0</boolean></kperf-bt><time-sample-kind id="12" fmt="Stackshot">3</time-sample-kind></row> +<row><sample-time ref="1"/><thread ref="2"/><sentinel/><thread-state ref="7"/><sentinel/><kperf-bt ref="8"/><time-sample-kind ref="12"/></row> +<row><sample-time id="13" fmt="00:00.852.188">852188791</sample-time><thread ref="2"/><core id="14" fmt="CPU 8 (S Core)">8</core><thread-state id="15" fmt="Running">Running</thread-state><sentinel/><kperf-bt id="16" fmt="PC:0x182ae23d0, 1 frames, 1 regs, pid: 83138"><text-addresses id="17" fmt="frag 7">0</text-addresses><text-address id="18" fmt="0x182ae23d0">6487417808</text-address><process ref="4"/><boolean ref="11"/><register-content id="19" fmt="0x182ae22d4">6487417556</register-content></kperf-bt><time-sample-kind id="20" fmt="Timer Fired">0</time-sample-kind></row> +<row><sample-time id="21" fmt="00:00.853.188">853188458</sample-time><thread ref="2"/><core id="22" fmt="CPU 6 (S Core)">6</core><thread-state ref="15"/><sentinel/><kperf-bt id="23" fmt="PC:0x182b11988, 8 frames, 1 regs, pid: 83138"><text-addresses id="24" fmt="frag 9">6487614000 6487524148 6487531772 6487491436 6487429752 6487426032 6487424332 0</text-addresses><text-address id="25" fmt="0x182b11988">6487611784</text-address><process ref="4"/><boolean ref="11"/><register-content id="26" fmt="0x182b11964">6487611748</register-content></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="27" fmt="00:00.854.188">854188666</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="28" fmt="PC:0x102d0ccd0, 6 frames, 1 regs, pid: 83138"><text-addresses id="29" fmt="frag 11">4342206600 4342206576 4342276004 4342206952 6487424420 0</text-addresses><text-address id="30" fmt="0x102d0ccd0">4342205648</text-address><process ref="4"/><boolean ref="11"/><register-content id="31" fmt="0x102d0cc30">4342205488</register-content></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="32" fmt="00:00.855.188">855188125</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="33" fmt="PC:0x102d12454, 8 frames, 1 regs, pid: 83138"><text-addresses id="34" fmt="frag 13">4342219512 4342205952 4342206600 4342206576 4342276004 4342206952 6487424420 0</text-addresses><text-address id="35" fmt="0x102d12454">4342228052</text-address><process ref="4"/><boolean ref="11"/><register-content id="36" fmt="0x101">257</register-content></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="37" fmt="00:00.856.188">856188125</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="38" fmt="PC:0x102d1232c, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="39" fmt="0x102d1232c">4342227756</text-address><process ref="4"/><boolean ref="11"/><register-content ref="36"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="40" fmt="00:00.857.188">857188833</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="41" fmt="PC:0x102d11dc8, 8 frames, 1 regs, pid: 83138"><text-addresses id="42" fmt="frag 16">4342219840 4342205952 4342206600 4342206576 4342276004 4342206952 6487424420 0</text-addresses><text-address id="43" fmt="0x102d11dc8">4342226376</text-address><process ref="4"/><boolean ref="11"/><register-content id="44" fmt="0x102d1186c">4342225004</register-content></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="45" fmt="00:00.858.188">858188166</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="46" fmt="PC:0x102d11484, 8 frames, 1 regs, pid: 83138"><text-addresses ref="42"/><text-address id="47" fmt="0x102d11484">4342224004</text-address><process ref="4"/><boolean ref="11"/><register-content id="48" fmt="0x102d1131c">4342223644</register-content></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="49" fmt="00:00.859.188">859188125</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="50" fmt="PC:0x102d114a0, 8 frames, 1 regs, pid: 83138"><text-addresses ref="42"/><text-address id="51" fmt="0x102d114a0">4342224032</text-address><process ref="4"/><boolean ref="11"/><register-content ref="48"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="52" fmt="00:00.860.188">860188250</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="53" fmt="PC:0x102d11a34, 8 frames, 1 regs, pid: 83138"><text-addresses ref="42"/><text-address id="54" fmt="0x102d11a34">4342225460</text-address><process ref="4"/><boolean ref="11"/><register-content ref="44"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="55" fmt="00:00.861.188">861188000</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="56" fmt="PC:0x102d1233c, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="57" fmt="0x102d1233c">4342227772</text-address><process ref="4"/><boolean ref="11"/><register-content ref="36"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="58" fmt="00:00.862.188">862188041</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="59" fmt="PC:0x102d12364, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="60" fmt="0x102d12364">4342227812</text-address><process ref="4"/><boolean ref="11"/><register-content ref="36"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="61" fmt="00:00.863.188">863188666</sample-time><thread ref="2"/><core id="62" fmt="CPU 7 (S Core)">7</core><thread-state ref="15"/><sentinel/><kperf-bt ref="56"/><time-sample-kind ref="20"/></row> +<row><sample-time id="63" fmt="00:00.864.188">864188125</sample-time><thread ref="2"/><core ref="62"/><thread-state ref="15"/><sentinel/><kperf-bt ref="38"/><time-sample-kind ref="20"/></row> +<row><sample-time id="64" fmt="00:00.865.188">865188416</sample-time><thread ref="2"/><core id="65" fmt="CPU 9 (S Core)">9</core><thread-state ref="15"/><sentinel/><kperf-bt id="66" fmt="PC:0x102d1234c, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="67" fmt="0x102d1234c">4342227788</text-address><process ref="4"/><boolean ref="11"/><register-content ref="36"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="68" fmt="00:00.866.188">866188166</sample-time><thread ref="2"/><core ref="65"/><thread-state ref="15"/><sentinel/><kperf-bt id="69" fmt="PC:0x102d12430, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="70" fmt="0x102d12430">4342228016</text-address><process ref="4"/><boolean ref="11"/><register-content ref="36"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="71" fmt="00:00.867.188">867188208</sample-time><thread ref="2"/><core ref="65"/><thread-state ref="15"/><sentinel/><kperf-bt id="72" fmt="PC:0x102d12368, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="73" fmt="0x102d12368">4342227816</text-address><process ref="4"/><boolean ref="11"/><register-content ref="36"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="74" fmt="00:00.868.188">868188208</sample-time><thread ref="2"/><core ref="65"/><thread-state ref="15"/><sentinel/><kperf-bt id="75" fmt="PC:0x102d11a7c, 8 frames, 1 regs, pid: 83138"><text-addresses ref="42"/><text-address id="76" fmt="0x102d11a7c">4342225532</text-address><process ref="4"/><boolean ref="11"/><register-content ref="44"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="77" fmt="00:00.869.188">869188375</sample-time><thread ref="2"/><core ref="65"/><thread-state ref="15"/><sentinel/><kperf-bt id="78" fmt="PC:0x182ea9418, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="79" fmt="0x182ea9418">6491378712</text-address><process ref="4"/><boolean ref="11"/><register-content id="80" fmt="0x102d12644">4342228548</register-content></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="81" fmt="00:00.870.188">870188000</sample-time><thread ref="2"/><core ref="65"/><thread-state ref="15"/><sentinel/><kperf-bt id="82" fmt="PC:0x102d1246c, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="83" fmt="0x102d1246c">4342228076</text-address><process ref="4"/><boolean ref="11"/><register-content ref="36"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="84" fmt="00:00.871.188">871188041</sample-time><thread ref="2"/><core ref="65"/><thread-state ref="15"/><sentinel/><kperf-bt ref="56"/><time-sample-kind ref="20"/></row> +<row><sample-time id="85" fmt="00:00.872.188">872188000</sample-time><thread ref="2"/><core ref="65"/><thread-state ref="15"/><sentinel/><kperf-bt id="86" fmt="PC:0x102d0d250, 10 frames, 1 regs, pid: 83138"><text-addresses id="87" fmt="frag 29">4342212464 4342224228 4342219840 4342205952 4342206600 4342206576 4342276004 4342206952 6487424420 0</text-addresses><text-address id="88" fmt="0x102d0d250">4342207056</text-address><process ref="4"/><boolean ref="11"/><register-content id="89" fmt="0x102d0e770">4342212464</register-content></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="90" fmt="00:00.873.188">873188458</sample-time><thread ref="2"/><core ref="65"/><thread-state ref="15"/><sentinel/><kperf-bt ref="66"/><time-sample-kind ref="20"/></row> +<row><sample-time id="91" fmt="00:00.874.188">874188041</sample-time><thread ref="2"/><core ref="65"/><thread-state ref="15"/><sentinel/><kperf-bt ref="66"/><time-sample-kind ref="20"/></row> +<row><sample-time id="92" fmt="00:00.875.188">875188416</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="93" fmt="PC:0x102d12404, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="94" fmt="0x102d12404">4342227972</text-address><process ref="4"/><boolean ref="11"/><register-content ref="36"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="95" fmt="00:00.876.188">876188125</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="96" fmt="PC:0x102d11a38, 8 frames, 1 regs, pid: 83138"><text-addresses ref="42"/><text-address id="97" fmt="0x102d11a38">4342225464</text-address><process ref="4"/><boolean ref="11"/><register-content ref="44"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="98" fmt="00:00.877.188">877188000</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="99" fmt="PC:0x182e5c10c, 16 frames, 1 regs, pid: 83138"><text-addresses id="100" fmt="frag 33">6489362016 6489425524 6489427600 4342458232 4342458428 4342207200 4342212464 4342224228 4342219840 4342205952 4342206600 4342206576 4342276004 4342206952 6487424420 0</text-addresses><text-address id="101" fmt="0x182e5c10c">6491062540</text-address><process ref="4"/><boolean ref="11"/><register-content id="102" fmt="0x182cbd484">6489363588</register-content></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="103" fmt="00:00.878.188">878188000</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="104" fmt="PC:0x102d12388, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="105" fmt="0x102d12388">4342227848</text-address><process ref="4"/><boolean ref="11"/><register-content ref="36"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="106" fmt="00:00.879.188">879188083</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt id="107" fmt="PC:0x102d124a0, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="108" fmt="0x102d124a0">4342228128</text-address><process ref="4"/><boolean ref="11"/><register-content ref="36"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="109" fmt="00:00.880.188">880188500</sample-time><thread ref="2"/><core ref="14"/><thread-state ref="15"/><sentinel/><kperf-bt id="110" fmt="PC:0x182ea9138, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="111" fmt="0x182ea9138">6491377976</text-address><process ref="4"/><boolean ref="11"/><register-content id="112" fmt="0x102d121e0">4342227424</register-content></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="113" fmt="00:00.881.188">881188041</sample-time><thread ref="2"/><core ref="14"/><thread-state ref="15"/><sentinel/><kperf-bt id="114" fmt="PC:0x102d122dc, 8 frames, 1 regs, pid: 83138"><text-addresses ref="34"/><text-address id="115" fmt="0x102d122dc">4342227676</text-address><process ref="4"/><boolean ref="11"/><register-content ref="36"/></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="116" fmt="00:00.882.188">882188166</sample-time><thread ref="2"/><core ref="62"/><thread-state ref="15"/><sentinel/><kperf-bt ref="66"/><time-sample-kind ref="20"/></row> +<row><sample-time id="117" fmt="00:00.883.188">883188041</sample-time><thread ref="2"/><core ref="14"/><thread-state ref="15"/><sentinel/><kperf-bt ref="66"/><time-sample-kind ref="20"/></row> +<row><sample-time id="118" fmt="00:00.884.188">884188500</sample-time><thread ref="2"/><core ref="14"/><thread-state ref="15"/><sentinel/><kperf-bt id="119" fmt="PC:0x102d1141c, 8 frames, 1 regs, pid: 83138"><text-addresses ref="42"/><text-address id="120" fmt="0x102d1141c">4342223900</text-address><process ref="4"/><boolean ref="11"/><register-content id="121" fmt="0x102d11ef4">4342226676</register-content></kperf-bt><time-sample-kind ref="20"/></row> +<row><sample-time id="122" fmt="00:00.885.188">885188250</sample-time><thread ref="2"/><core ref="14"/><thread-state ref="15"/><sentinel/><kperf-bt ref="69"/><time-sample-kind ref="20"/></row> +<row><sample-time id="123" fmt="00:00.886.188">886188208</sample-time><thread ref="2"/><core ref="22"/><thread-state ref="15"/><sentinel/><kperf-bt ref="82"/><time-sample-kind ref="20"/></row> +<row><sample-time id="124" fmt="00:00.887.188">887188208</sample-time><thread ref="2"/><core ref="14"/><thread-state ref="15"/><sentinel/><kperf-bt ref="82"/><time-sample-kind ref="20"/></row> +<row><sample-time id="125" fmt="00:00.888.188">888188291</sample-time><thread ref="2"/><core ref="62"/><thread-state ref="15"/><sentinel/><kperf-bt ref="53"/><time-sample-kind ref="20"/></row> +</node></trace-query-result> 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<jp_workspace::Error> 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<ConversationEvent>)>) -> 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.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<String>) -> 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_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<Printer>, 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_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<TurnStart> 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<EventKind> { + 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<Utc>` 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<chrono::Utc>) -> String { } /// Parse from `time`'s format or RFC 3339. -fn parse_dt(s: &str) -> Result<chrono::DateTime<chrono::Utc>, String> { +pub(crate) fn parse_dt(s: &str) -> Result<chrono::DateTime<chrono::Utc>, 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<Utc>) -> 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<String> { + 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<Item = StreamEntry<'_>> { + 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. 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<DisplayEvent>, +} + +/// 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<String>, + + 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<DisplayTurn> { + let mut turns: Vec<DisplayTurn> = 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<DisplayEvent> { + 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<Utc> { + datetime!(2024-09-01 10:00:00 Z) +} + +fn event(kind: impl Into<EventKind>) -> 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<EventKind>) -> ConversationStream { + events(kinds.into_iter().map(event).collect()) +} + +/// A stream holding `events`. +fn events(events: Vec<ConversationEvent>) -> 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<usize> = 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<usize> = 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<Option<CString>> = 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<T>(label: &str, body: impl FnOnce() -> Result<T, String>) -> Option<T> { + 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<CString> { + 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<String>, + xdg: Option<String>, +} + +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<String> { + serde_json::from_str::<Vec<serde_json::Value>>(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<String> { + 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<Span>, +} + +impl Timings { + /// Run `work`, recording how long it took under `name`. + pub(crate) fn measure<T>(&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> { + 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/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<Utc>, + /// When the conversation was pinned, absent if it is not pinned. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pinned_at: Option<DateTime<Utc>>, + /// Number of events in the conversation. pub events_count: usize, } 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<dyn SessionBackend>, + /// The filesystem backend, for workspaces opened from disk. + fs: Option<Arc<FsStorageBackend>>, + /// 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<Utf8PathBuf>) -> Self { - Self::new_with_id(root, id::Id::new()) + pub fn in_memory(root: impl Into<Utf8PathBuf>) -> 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<Utf8PathBuf>, id: id::Id) -> Self { + pub fn in_memory_with_id(root: impl Into<Utf8PathBuf>, 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> { + 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<Self> { + 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<FsStorageBackend>> { + 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<dyn SessionBackend> { + &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<Utf8PathBuf>, 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<dyn jp_storage::backend::LoadBackend>) .with_sessions(fs as Arc<dyn jp_storage::backend::SessionBackend>) .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<FsStorageBackend>, 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<Arc<FsStorageBackend>>) { .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 { 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, }], }); diff --git a/justfile b/justfile index 7dba3a2d6..34a114858 100644 --- a/justfile +++ b/justfile @@ -3,6 +3,7 @@ set fallback # see: <https://github.com/cargo-bins/cargo-quickinstall/releases> 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,252 @@ 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 + +# 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 +# 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 + +# 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: