Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .config/jp/tools/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
90 changes: 90 additions & 0 deletions .config/jp/tools/src/debug_app.rs
Original file line number Diff line number Diff line change
@@ -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),
}
}
97 changes: 97 additions & 0 deletions .config/jp/tools/src/debug_app/ambient.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pointer: Option<(f64, f64)>,
}

#[derive(Deserialize)]
struct FrontmostReport {
bundle_id: Option<String>,
}

#[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::<FrontmostReport>(bin, &["frontmost"], root, runner)
.and_then(|report| report.bundle_id),
pointer: read::<PointerReport>(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<T: for<'de> Deserialize<'de>>(
bin: &Utf8Path,
args: &[&str],
root: &Utf8Path,
runner: &dyn ProcessRunner,
) -> Option<T> {
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;
84 changes: 84 additions & 0 deletions .config/jp/tools/src/debug_app/ambient_tests.rs
Original file line number Diff line number Diff line change
@@ -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,
);
}
Loading
Loading