ci(github): Build and test the macOS app - #1014
Open
JeanMertz wants to merge 8 commits into
Open
Conversation
`jp_cli::load_workspace` assembled a workspace from disk by hand: find the root, load the ID, build the filesystem backend, wire the user-local silo, then store the ID back. That is workspace knowledge living in the CLI, and a second consumer has to repeat it. Repeating it wrong is silent: wiring the filesystem backend without user-local storage still compiles and still returns conversations, just fewer of them. `Workspace::open` owns that sequence now, with `DEFAULT_STORAGE_DIR` moved alongside it. A new `Error::WorkspaceNotFound` carries the directory that was searched, so the CLI keeps printing its `jp init` hint while other callers receive a typed error rather than a formatted string. The two in-memory constructors become `in_memory` and `in_memory_with_id`, so each constructor says which side of the disk it sits on. Two accessors come with them: `fs_storage` returns the filesystem backend an opened workspace holds, and `sessions` returns the session backend, which the `--no-persist` path wraps in `ReadOnlySessionBackend` instead of reaching for the filesystem backend that happened to be serving that role. Behavior is unchanged. A test covers the wiring that is otherwise invisible when wrong: a conversation created with `--local` lives only in the user-local silo, and a workspace opened from disk must list it. Signed-off-by: Jean Mertz <git@jeanmertz.com>
A reader outside this workspace — the FFI boundary, a plugin, a web view — needs two things the crate did not offer. It needs timestamps in a format its platform can parse, and it needs to know what an entry in the stream is without decoding the storage encoding itself. `rfc3339` formats a timestamp the one way every platform's date parser accepts. Storage keeps timestamps in `time`'s human-readable spelling (`2024-09-01 10:00:00.0`), which nothing outside Rust reads, and each reader otherwise reinvents the conversion. Sub-second precision is kept when the value has any. `rfc3339_str` does the same for a caller holding raw JSON rather than a typed event, returning `None` for a value that parses as neither spelling so the caller can leave it as it found it. `EventKind::type_tag` returns the tag serde writes, which is what a reader switches on. `as_str` returns the Rust name and is for messages addressed to somebody reading this code; the two were the same string by coincidence and nothing held them that way. Two tests do now: one checks every variant's tag against what serde actually serializes, the other against `TYPE_TAGS`, the list the deserializer uses to decide whether an entry is a known event. A tag missing from that list makes its variant unreachable, and the stream keeps every such event as raw JSON instead. `StreamEntry` and `ConversationStream::iter_entries` give a borrowed view of every entry in order, including the config deltas, compaction overlays and unrecognized entries that `iter` and `iter_events_by_turn` skip. Those are part of what happened, and a reader presenting the stream to somebody wants them. Nothing is copied or re-encoded on the way out. Signed-off-by: Jean Mertz <git@jeanmertz.com>
A plugin listing conversations received the title, the last activation time and the event count, but nothing about pinning. A pinned conversation was indistinguishable from any other, so a plugin could neither mark it nor sort by it without opening every conversation to find out. `ConversationSummary` carries the `pinned_at` timestamp the conversation metadata already holds. The field is skipped when serializing an unpinned conversation and defaults to absent when reading, so a plugin built against the older shape keeps working and one built against the newer shape reads an older host's messages. Signed-off-by: Jean Mertz <git@jeanmertz.com>
A native reader cannot call `jp_workspace` without a boundary, and the options for building one are all worse than a direct link. Shelling out to `jp --format json` binds to shapes `jp_cli::output` derives from table rows, which are not stable and would be frozen by Hyrum's Law the moment anything depended on them, and pays a full workspace load per read. Reading `.jp/` from the other language reimplements the storage format twice over and makes the on-disk layout a public contract. A sidecar process buys failure isolation that a read-only viewer does not need, at the cost of supervision and a protocol. `jp_ffi` compiles as a static library beside an rlib and exposes six entry points: open a workspace, list its conversations, read one conversation's turns, close the handle, free a returned string, and collect the last error. The rlib is what lets those entry points be unit-tested in-process rather than only through a linked app. Four rules hold the boundary together. Every entry point catches panics, because unwinding into the calling language is undefined behavior; a caught panic becomes a null return and a message. Failures return null and leave that message in a thread-local slot for `jp_last_error`. Only owned data crosses: a read copies out and drops its lock guard before returning, so no guard, reference or borrow escapes. And Rust frees what Rust allocates, which is what `jp_string_free` is for. Reads also measure their own phases and report them through an optional out-parameter. The timings ride back on the call that produced them rather than on a call of their own, so they cannot be attributed to the wrong read when two overlap, and they are durations rather than timestamps because the caller already has a clock and two that nearly agree are worse than one. Measuring happens here; writing does not. `display` decides what a reader shows. Which events carry prose, and where turn boundaries fall, are judgements about the conversation model rather than about any one reader: events before the first `TurnStart` form an implicit leading turn, and a `TurnStart` opens a new one only when the turn before it holds something. Neither is recoverable from the event shape, so both belong on this side rather than being re-derived by every reader. It reads the typed stream instead of a serialized copy, which would base64-encode the fields storage encodes only to decode them again, reparse every timestamp, and allocate a second copy of the conversation to read four fields off it. The crate depends on `jp_workspace`, `jp_conversation` and `jp_plugin`. Not `jp_config`, and not `jp_cli`: a reader has no per-tool style to apply, no reasoning display mode and no hidden-tool filtering, so keeping `jp_config` out keeps the layered load pipeline out with it. `just build-ffi` builds the library, stages it with a generated header under `apps/macos/.build/<profile>`, and is what Xcode invokes from a build phase so there is one build entry point rather than two competing ones. It asks cargo where it writes rather than assuming `./target`, because the target directory is redirectable and sibling worktrees here share one outside the checkout. Signed-off-by: Jean Mertz <git@jeanmertz.com>
Conversations are readable from the terminal and, through the plugin system, from a browser. Neither suits browsing: scrolling back through weeks of turns, skimming several conversations side by side, or reading on the machine where the work happened. This is a real Mac app for that, with native windows, tabs, menus and state restoration rather than a web view in a window. It is a viewer. It opens a workspace and reads conversations, never writes them, and has no compose field. A window shows the conversation list beside the transcript, split by a divider whose position is restored across launches, and opening any directory inside a workspace opens that workspace, matching how `jp` itself walks up to find `.jp`. The scene is a plain `WindowGroup`. Keying it by workspace path made a window's identity its workspace, so ⌘N on a workspace already on screen brought that window forward instead of opening one, and ⌘T had nothing to duplicate. Each window decides which workspace it shows and keeps that choice in its own scene storage. Timestamps arrive as text and stay that way. The Rust side emits a fractional-seconds part whenever the stored value has one, and `JSONDecoder`'s `.iso8601` strategy rejects those, so a `Date` on the Swift struct would decode the whole-second case and fail on every real workspace. `ConversationDate` parses at the point of display instead. `ConversationSummary` and the event payloads are hand-maintained mirrors of Rust types with no compiler checking that they agree, so each one has a test pinning the exact JSON it decodes. The timings payload is pinned on both sides — `WorkspaceReaderTests` and `jp_ffi`'s `timing_tests` hold the same literal — and if one is edited alone the other is what says so. `project.yml` is the source of truth for the Xcode project, which is generated by `just gen-app` rather than committed, so targets, build settings and the Rust build phase stay reviewable. Swift 6 language mode with complete concurrency checking is on and warnings are errors, holding the app to the bar the Rust side is held to. Debug builds carry `dwarf-with-dsym` and disable Xcode's debug dylib split, because a profiler pointed at the bundle's executable otherwise reads a launcher stub whose UUID matches nothing in the trace. `just run-app` launches it in the foreground with output attached, and `just open-app` goes through LaunchServices for the AppKit behavior that only a normally registered launch produces. Signed-off-by: Jean Mertz <git@jeanmertz.com>
Debugging the macOS app from a conversation needs a way to read what is actually on screen and act on it. Screenshots answer "what does it look like" but not "what is the button called, is it enabled, does the menu item exist" — and none of that is reachable from the app's own test bundle, which runs inside the process and cannot see menu enablement, the pasteboard, or a relaunch. `jpdrive` reads and drives another application through its accessibility tree, and writes one JSON document to stdout either way: a result, or an error with a non-zero exit status. It is what the `debug_app_*` tools shell out to. A standalone SwiftPM package rather than a target in the app's Xcode project, so the binary lands at a predictable path and nothing has to search derived data for it. The logic lives in a `DriveKit` library with a one-line executable on top, because SwiftPM cannot cleanly test an executable target and the tree traversal is where the bugs are. The tests run against a fake accessibility tree, so they need no running app and no accessibility grant. The package mirrors the app's strictness: Swift 6 language mode, `ExistentialAny`, and warnings as errors. SwiftPM 6.0 has no first-class setting for the last of those, so it goes through `unsafeFlags`, which is rejected only for a package consumed as a dependency — this one never is. Accessibility is gated by TCC, and a grant given to a terminal does not obviously reach a tool that terminal started. `just drive-doctor` reports whether the calling process may read another app's tree, so that question is answered by running it under each host rather than guessed at. Signed-off-by: Jean Mertz <git@jeanmertz.com>
The app's unit tests run inside the app process, which puts three things out of reach: whether a menu item is enabled, what landed on the pasteboard, and what survives a terminate and relaunch. Those are exactly the behaviours that break without anyone noticing, and `QA.md` carried them as a manual checklist. This is the half of that checklist a machine can run. A UI test drives the app through its accessibility tree from a separate process, so `@testable import JP` is unavailable and must not be reached for. Anything checkable in-process belongs in `JPTests`, where it costs milliseconds instead of an app launch. Three things make the suite fast enough to be worth having. A suite shares one launched app, because launching costs about four seconds and the work under test costs milliseconds — so a test leaves the app as it found it, or the suite is ordered so that what one test leaves is what the next expects. Waits are on conditions rather than the clock, and not through `waitForExistence`, which reports an element about a second after it appears whatever the element; asking again finds it in under 100ms, and the timeout becomes the price of a failure rather than of a pass. And every animation goes through one lever the tests turn off, because XCUITest waits for the app to stop moving before each action it synthesizes. Anything the tests need the app to do differently goes through `DebugState`, gated on `#if DEBUG` and off unless an environment variable says otherwise, so a release build has no way to reach it. The pasteboard works this way and is enforced: no test may touch the system pasteboard, a debug build copies wherever `JP_DEBUG_PASTEBOARD` names, and `ClipboardPolicyTests` fails on any spelling of the general one. `just test-app-ui` is the CI job and runs everything even after a failure. While writing a test, run it by name instead. `fmt-app` and `lint-app` arrive here rather than with the app because they name every Swift directory in the repository, and this is the last one to exist. Signed-off-by: Jean Mertz <git@jeanmertz.com>
Nothing in CI compiled the Swift half of the repository, so the app, the UI tests and the jpdrive package were verified only by whoever remembered to run them locally. The strict compiler settings and warnings-as-errors the app is held to are worth little if no automated run enforces them. Runs as its own workflow rather than as entries in the Rust matrix, whose rustup, sccache and target caching two of these four tasks have no use for. Each task is gated on the paths that can break it: the two Swift-only tasks watch \`apps/macos\`, and \`test-app\` watches Rust as well, because the app links \`jp_ffi\` and a change in a crate beneath it can break the build without touching a Swift file. Signed-off-by: Jean Mertz <git@jeanmertz.com>
JeanMertz
force-pushed
the
pr/uitests
branch
9 times, most recently
from
August 21, 2026 20:20
7020645 to
c7ecc49
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Nothing in CI compiled the Swift half of the repository, so the app, the
UI tests and the jpdrive package were verified only by whoever remembered
to run them locally. The strict compiler settings and warnings-as-errors
the app is held to are worth little if no automated run enforces them.
Runs as its own workflow rather than as entries in the Rust matrix, whose
rustup, sccache and target caching two of these four tasks have no use
for. Each task is gated on the paths that can break it: the two
Swift-only tasks watch `apps/macos`, and `test-app` watches Rust as
well, because the app links `jp_ffi` and a change in a crate beneath it
can break the build without touching a Swift file.
Signed-off-by: Jean Mertz git@jeanmertz.com