Skip to content

ci(github): Build and test the macOS app - #1014

Open
JeanMertz wants to merge 8 commits into
pr/uitestsfrom
pr/app-ci
Open

ci(github): Build and test the macOS app#1014
JeanMertz wants to merge 8 commits into
pr/uitestsfrom
pr/app-ci

Conversation

@JeanMertz

Copy link
Copy Markdown
Collaborator

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

`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
JeanMertz force-pushed the pr/uitests branch 9 times, most recently from 7020645 to c7ecc49 Compare August 21, 2026 20:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant