diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml new file mode 100644 index 000000000..d1d8118be --- /dev/null +++ b/.github/workflows/app.yml @@ -0,0 +1,134 @@ +name: app +on: + pull_request: + push: + branches: + - main +env: + CARGO_TERM_COLOR: always + JUST_TIMESTAMP: true + JUST_COLOR: always + JUST_EXPLAIN: true + JUST_VERBOSE: 4 +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +jobs: + changes: + runs-on: ubuntu-latest + outputs: + lint-app: ${{ steps.filter.outputs.lint-app }} + test-drive: ${{ steps.filter.outputs.test-drive }} + test-app: ${{ steps.filter.outputs.test-app }} + test-app-ui: ${{ steps.filter.outputs.test-app-ui }} + steps: + - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v6 + with: + fetch-depth: 0 + - id: filter + shell: bash + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "pull_request" ]; then + git diff --name-only HEAD^1 HEAD > changed-files.txt + else + before="${{ github.event.before }}" + if [ "$before" = "0000000000000000000000000000000000000000" ]; then + git diff-tree --no-commit-id --name-only -r HEAD > changed-files.txt + else + git diff --name-only "$before" HEAD > changed-files.txt + fi + fi + + matches() { + grep -Eq "$1" changed-files.txt + } + + set_task() { + if matches "$2"; then + echo "$1=true" >> "$GITHUB_OUTPUT" + else + echo "$1=false" >> "$GITHUB_OUTPUT" + fi + } + + workflow='^\.github/workflows/app\.yml$' + justfile='^justfile$' + swift='^apps/macos/' + drive='^apps/macos/Tools/' + # The app links `jp_ffi`, which links most of the workspace, so a + # Rust change anywhere can break the build it cannot break the Swift + # sources. `test-app` therefore watches Rust as well; the two Swift- + # only tasks do not. + rust='\.rs$|(^|/)Cargo\.toml$|^Cargo\.lock$|^rust-toolchain\.toml$' + + set_task lint-app "$swift|$workflow|$justfile" + set_task test-drive "$drive|$workflow|$justfile" + set_task test-app "$swift|$rust|$workflow|$justfile" + set_task test-app-ui "$swift|$workflow|$justfile" + app: + needs: changes + runs-on: macos-latest + strategy: + # One failing task should not cancel the others: a formatting failure and + # a test failure are separate pieces of information, and a macOS run is + # too slow to pay for twice. + fail-fast: false + matrix: + task: [lint-app, test-drive, test-app, test-app-ui] + name: ${{ matrix.task }} + # The UI tests drive the app through the screen and are the slowest thing + # here by a wide margin. Cap the job so a hung run does not burn macOS + # minutes unnoticed. + timeout-minutes: 45 + steps: + - name: Decide whether to run task + id: task + shell: bash + env: + TASK: ${{ matrix.task }} + LINT_APP: ${{ needs.changes.outputs.lint-app }} + TEST_DRIVE: ${{ needs.changes.outputs.test-drive }} + TEST_APP: ${{ needs.changes.outputs.test-app }} + TEST_APP_UI: ${{ needs.changes.outputs.test-app-ui }} + run: | + set -euo pipefail + + case "$TASK" in + lint-app) run="$LINT_APP" ;; + test-drive) run="$TEST_DRIVE" ;; + test-app) run="$TEST_APP" ;; + test-app-ui) run="$TEST_APP_UI" ;; + *) echo "unknown task: $TASK" >&2; exit 1 ;; + esac + + echo "run=$run" >> "$GITHUB_OUTPUT" + if [ "$run" != "true" ]; then + echo "Skipping $TASK because no relevant files changed." + fi + - if: ${{ steps.task.outputs.run == 'true' }} + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v6 + - if: ${{ steps.task.outputs.run == 'true' }} + uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 + # Recorded on every run so a failure that turns out to be a toolchain + # difference is diagnosable from the log rather than by bisecting the + # runner image. + - name: Report toolchain versions + if: ${{ steps.task.outputs.run == 'true' }} + run: | + xcodebuild -version + swift --version + # Only the tasks that build the app need the project generated, and only + # they need cargo: `lint-app` and `test-drive` touch neither. + - if: ${{ steps.task.outputs.run == 'true' && (matrix.task == 'test-app' || matrix.task == 'test-app-ui') }} + run: brew install xcodegen + - if: ${{ steps.task.outputs.run == 'true' && (matrix.task == 'test-app' || matrix.task == 'test-app-ui') }} + uses: Swatinem/rust-cache@7e1e2d0a10862b34e5df481373b2b0f295d1a2ef # v2 + with: + key: ${{ matrix.task }} + cache-bin: false + cache-workspace-crates: true + save-if: ${{ github.ref == 'refs/heads/main' }} + - if: ${{ steps.task.outputs.run == 'true' }} + run: just ${{ matrix.task }} diff --git a/.gitignore b/.gitignore index 966a2be60..aa7479a3c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,17 @@ /rustc-ice-* lcov.info +# macOS app: the Xcode project is generated from `apps/macos/project.yml` by +# `just gen-app`, and `.build/` holds the staged `jp_ffi` library and header, so +# only the manifest and the sources are tracked. +/apps/macos/*.xcodeproj +/apps/macos/.build +# SwiftPM's build directory for the `jpdrive` package, which is built by +# `just build-drive` rather than by Xcode. +/apps/macos/Tools/*/.build +/apps/macos/**/xcuserdata +.DS_Store + # Logs *.log /tmp diff --git a/.ignore b/.ignore index be3fdec5c..75843d6ab 100644 --- a/.ignore +++ b/.ignore @@ -1,6 +1,8 @@ # Whitelist: ignore everything, then un-ignore desired trees * !/* +!apps/ +!apps/** !crates/ !crates/contrib/ !crates/contrib/** @@ -45,6 +47,12 @@ docs/.vitepress/dist/ docs/.pnp.* docs/yarn.lock **/target/** +# Generated by `just build-ffi` and `just gen-app`; both are gitignored, and the +# `.xcodeproj` is regenerated from `apps/macos/project.yml` on every build. +apps/macos/.build/ +apps/macos/*.xcodeproj/ +# SwiftPM's build directory for the `jpdrive` package. +apps/macos/Tools/*/.build/ .git/ /tmp **/fixtures/ diff --git a/Cargo.lock b/Cargo.lock index c87759a0c..23b9739fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2375,6 +2375,25 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "jp_ffi" +version = "0.1.0" +dependencies = [ + "camino", + "camino-tempfile", + "chrono", + "datetime_literal", + "jp_conversation", + "jp_plugin", + "jp_storage", + "jp_workspace", + "pretty_assertions", + "serde", + "serde_json", + "serial_test", + "tracing", +] + [[package]] name = "jp_github" version = "0.1.0" diff --git a/apps/macos/.swift-format b/apps/macos/.swift-format new file mode 100644 index 000000000..88c1c32f0 --- /dev/null +++ b/apps/macos/.swift-format @@ -0,0 +1,29 @@ +{ + "version": 1, + "lineLength": 96, + "indentation": { "spaces": 4 }, + "respectsExistingLineBreaks": true, + "lineBreakBeforeEachArgument": false, + "prioritizeKeepingFunctionOutputTogether": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": true, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "DontRepeatTypeInStaticProperties": true, + "NeverForceUnwrap": true, + "NeverUseForceTry": true, + "NeverUseImplicitlyUnwrappedOptionals": true, + "NoLeadingUnderscores": true, + "OmitExplicitReturns": false, + "OneCasePerLine": true, + "OnlyOneTrailingClosureArgument": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "UseEarlyExits": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": true, + "ValidateDocumentationComments": true + } +} diff --git a/apps/macos/AFFORDANCES.md b/apps/macos/AFFORDANCES.md new file mode 100644 index 000000000..2461a0feb --- /dev/null +++ b/apps/macos/AFFORDANCES.md @@ -0,0 +1,276 @@ +# Affordance map + +What the JP reader responds to, and what each response is meant to do. This is +the contract phase 4 of [RFD 099] holds the app to; the QA checklist beside it +([`QA.md`](QA.md)) is how it gets checked by hand. + +Anything listed here that is not implemented says so. + +## Menus + +| Menu | Item | Shortcut | Does | +| ------- | ------------------- | -------- | ----------------------------------------------------------- | +| JP | About JP | | Standard. | +| JP | Quit JP | ⌘Q | Standard. | +| File | New Window | ⌘N | Opens a window on the most recently opened workspace. | +| File | Open Workspace… | ⌘O | Directory chooser; any directory inside a workspace opens it. | +| File | Open Recent ▸ | | Workspaces opened before, newest first. | +| File | Open Recent ▸ Clear | | Empties the list. | +| File | Close | ⌘W | Closes the window, or the frontmost tab. | +| Edit | Copy Link | ⇧⌘C | Copies the selected conversation's `jp://` URI. | +| View | Hide/Show Sidebar | ⌃⌘S | Hides or shows the conversation list. | +| View | Show All Tabs | ⇧⌘\ | Standard. | +| Window | Show Previous Tab | ⌃⇧⇥ | Standard. | +| Window | Merge All Windows | | Standard. | + +`File ▸ Open Workspace` and `Open Recent` act on the frontmost window rather than +opening a new one, so they are disabled when no window has focus. ⌘N first. + +**New Window opens the last workspace rather than an empty window.** A window +with no workspace can do nothing but ask for one, and the overwhelmingly likely +answer is the workspace you were just reading. This is also what makes ⌘W on the +last tab acceptable: closing is cheap because reopening is one keystroke and lands +you back where you were. + +## Conversation list + +| Input | Does | +| --------------------- | ---------------------------------------------------------- | +| Click | Selects; the transcript follows. | +| Double-click | Opens the conversation in its own window. | +| ↑ / ↓ | Moves the selection. | +| Escape | Clears the selection; the transcript pane empties. | +| Right-click | Context menu: Open in New Window, Copy Link. | +| Drag | Drags the conversation out as a `jp://` URI. | +| Type in the filter | Narrows the list to titles containing what was typed. | +| Click the clear button | Empties the filter box, restoring the whole list. | +| Drag the divider | Resizes the sidebar, between 220 and 480 points. | + +The whole row is a click target, including the padding around the text. + +A row shows the conversation's title over its date and event count, and a pinned +conversation carries a pin glyph in the accent colour beside them. Rows are a +fixed height, sized for a title of two lines: a list has to know its total +content height to size a scroll bar, and variable-height rows mean measuring +every row rather than the visible ones. + +A row draws its own background, its selection and the line under it. The list +contributes none of the three: its separators run edge to edge and its selection +is a full-width fill in the system accent colour. The table view's own selection +drawing is turned off outright — see `Sources/ListSelectionHighlight.swift` — +because nothing drawn above it hides it reliably. + +The selected row is a rounded fill with a thick accent bar down its leading edge, +both clipped to the same shape. **No line is drawn against a selected row**, above +or below it, so that fill is not cut across at either end. + +A title wraps to a second line before it truncates. The row is a fixed height, so +the space a one-line title leaves is simply empty — which is where Bear puts a +content preview, and where one would go. + +**A line above the first row appears only once the list is scrolled away from the +top.** At rest there is nothing to separate it from; scrolled, it separates the +search field from the rows passing under it. + +**Pinned conversations sort above the rest**, keeping the library's +most-recently-active order inside each group, so pinning lifts one conversation +and moves nothing else. + +The filter's clear button is always there, whether or not there is anything to +clear. A control that comes and goes with what has been typed moves the text's +right edge as it appears. + +Selection, double-click and the context menu are all the list's own, through +`contextMenu(forSelectionType:primaryAction:)`, rather than gestures attached to +each row. That is both why they behave like every other Mac list and why +scrolling a large sidebar stays cheap: a per-row context menu is rebuilt for +every row the list realizes. + +The context menu acts on the whole selection, so Open in New Window on three +selected conversations opens three windows and Copy Link copies three URIs, one +per line. + +Edit ▸ Copy Link copies the same URI for the selected conversation, and is what +reaches it without a pointing device. It takes ⇧⌘C rather than ⌘C so the +transcript keeps the shortcut for copying selected text. It is greyed out while +nothing is selected. + +Escape clears the selection, which is the only way back to an empty transcript +pane once a conversation has been read. It belongs to the list, so Escape while +the filter box has focus still means "clear what I typed". + +**Copy and drag produce a `jp://` URI**, which is the form JP itself uses to +reference a conversation, so pasting into a terminal or a query is useful. Not a +markdown file — that is [noted as future work](#not-implemented). + +## Transcript + +| Input | Does | +| -------------- | ----------------------------------------------------------- | +| Select text | Selects across the whole transcript, not just one message. | +| ⌘C | Copies the selected text. | +| Scroll | Scrolls; the scroll bar reflects the real height. | +| Drag the window edge | Re-wraps the text as the window moves, at any scroll position. | + +**The whole conversation is one text view.** Not a stack of one view per +message: a text view lays out what its viewport needs and re-wraps +incrementally, where a stack of views each measure themselves and a width change +costs the sum of them. That is also why selection runs across messages rather +than stopping at one, and why the scroll bar can state a real height instead of +an estimate. + +**Only messages are shown.** A user message and an assistant message each render +under the name of whoever said it. Tool calls, reasoning, inquiries, config +changes and turn markers have no prose to show and never cross the FFI boundary +— the library leaves them out rather than the app filtering them. Tool calls, +attachments and reasoning display are Non-Goals of RFD 099. + +**Messages are grouped into turns.** A turn is one user request through the +assistant's final answer to it. Where the boundaries fall is decided on the +library side, because the rules are not recoverable from the events alone: there +is an implicit leading turn, and a marker that opens a turn only sometimes. The +boundary is drawn as space — the gap above the first message of a turn is wider +than the gap between two messages inside one. + +**Block markdown renders**: headings, ordered and unordered lists with nesting, +fenced code blocks, block quotes, thematic breaks, and the inline set (bold, +italic, `code`, links, strikethrough). Soft line breaks reflow into the +paragraph, per CommonMark, which is what the terminal renderer does too. + +**Tables lay out in columns**, one row per line, each cell carried to its column +by a tab stop. A column takes the alignment the source declared — `---:` in the +separator row right-aligns it — and the header row is bold. Column width is +fixed rather than measured: measuring means laying every cell out at a width the +container has not settled on, and redoing it on every resize, for a reader +rather than an editor. + +Deliberately not `NSTextTable`, which would give real cell boxes and is a +TextKit 1 feature — putting one in the string drags the text view off TextKit 2 +silently, taking viewport layout with it. + +There is no reading-width cap. One text view re-wraps cheaply enough that +capping the column bought nothing, and the cap that used to be here never +engaged on a wide display anyway. + +The text view runs on **TextKit 1**, with contiguous layout, so the document +height is exact and the scroll bar states it rather than estimating. That is the +reason for the choice: an honest scroll bar was a goal, and TextKit 2's height is +an estimate that refines as it scrolls, which moves the knob under the pointer. + +It costs real work. The same ten programmatic resizes measured 438 samples here +against 155 on TextKit 2 for a 29-event conversation, and 412 against 355 for a +167-event one — so TextKit 1 is flat with document size where TextKit 2 scales, +and the gap narrows as conversations grow. Revisit if a long conversation starts +feeling slow to resize; `Sources/TranscriptTextView.swift` has it behind one +named constant. + +**The text container's width is set by hand on every frame of a drag.** A text +view normally hands its width to the container it is tracked by, and does not do +that while a live resize is in progress — the container keeps the width the drag +started from until the mouse comes up. Nothing then invalidates layout, and the +view faithfully redraws lines wrapped to a width the window no longer has. See +`Sources/TranscriptTextView.swift`; `TranscriptReflowTests` holds it. + +## Windows + +- **No title bar.** A workspace window's content runs to the top of the window, + with the close, minimize and zoom buttons over the sidebar's top-left corner and + the search field beside them. The window still carries a title — it is what the + Window menu lists and what an external driver addresses it by — but nothing + displays it. +- **The window buttons are moved.** macOS puts them six points in and centres them + fourteen points down, which is the middle of a title bar this window does not + have. They are placed against the search field instead, and put back whenever + AppKit lays the title bar out afresh. There is no supported way to ask for this: + a title bar grows to fit a toolbar, and a toolbar would span the whole window. + See `Sources/WindowButtons.swift`. +- **No sidebar toggle button**, because there is no title bar to put one in. + View ▸ Hide Sidebar (⌃⌘S) is how the sidebar is hidden and brought back. +- **The window holds its two panes itself**, rather than in a + `NavigationSplitView`. `NSSplitView` draws a translucent divider over whatever + is behind it and offers no way to change its colour or width, which left the + line between the panes two pixels of two different greys that shifted with the + content underneath. The divider is now the app's own: two points, one colour, + and draggable through a wider invisible strip around it. +- **Restored per window**: the workspace, the selected conversation, the + transcript's scroll position, the sidebar's width, and whether the sidebar is + showing. +- **One window per workspace**, keyed by the workspace path. Opening the same + workspace twice reaches the same window rather than making a second one, which + is why the path is canonicalized before it is used as the key. +- **A conversation can be pulled into its own window** by double-clicking. That + window carries the workspace path with it, so it can be restored at launch with + no workspace window open. +- **Native tabbing**, through Window ▸ Merge All Windows and the tab bar. + +## Accessibility + +- The conversation list is labelled `Conversations`. +- Each row is one accessibility element combining the title and event count, + rather than two unrelated fragments. A pinned row appends `, pinned`. +- A row's label leaves out the date it displays. The date is relative for + anything active today, so a label carrying it would read differently one + minute later and could not be pinned by a test. +- The transcript is selectable text, so VoiceOver reads messages as text. +- **There is no element per message.** The conversation is one text area, and its + value is every message it is showing. A driver addresses `transcript.text` and + reads that value; a test asserting on what is on screen compares it whole, + which catches a missing speaker label or a duplicated message that a search for + one phrase would not. + +### Identifiers + +Every element an external driver has to find carries an accessibility +identifier, so it can be reached without matching display text. The names live +in `Sources/AccessibilityID.swift` and are pinned by `AccessibilityIDTests`. + +| Identifier | Element | +| ------------------------------ | -------------------------------------- | +| `sidebar.state.loading` | Spinner while the workspace is read. | +| `sidebar.filter` | The box that narrows the list. | +| `sidebar.filter.clear` | The button that empties the filter box, always present. | +| `sidebar.list` | The conversation list. | +| `sidebar.row.` | One row. | +| `sidebar.state.nomatches` | Message shown when a filter matches none. | +| `sidebar.state.unavailable` | Message shown instead of a list. | +| `transcript.state.loading` | Spinner while a conversation is read. | +| `transcript.scroll` | The scrolling transcript. | +| `transcript.text` | The text the transcript is drawn as. | +| `transcript.state.unavailable` | Message shown instead of a transcript. | + +A row is named by the conversation's ID, so retitling a conversation does not +move it. There is no `sidebar.state.loaded` or `transcript.state.loaded`: a view +carries one identifier, and `sidebar.list` and `transcript.scroll` exist only in +that state, so they are the predicate. + +## Not implemented + +Named here so the gaps are visible rather than discovered. + +- **Drag produces a URI, not a markdown file.** Dropping into Finder therefore + does nothing useful. Filed as future work; it needs a presentation-neutral + conversation-to-markdown projection, which RFD 099 lists under Non-Goals. +- **No drop targets.** Nothing accepts a dragged conversation, including other JP + windows. +- **No live updates.** A window loads its workspace once. Turns written by a + concurrent `jp query` are invisible until the workspace is reopened. This is + RFD 099's stated v0.1 behavior. +- **A table column is a fixed width**, so a cell longer than one wraps into the + next column's space rather than widening it. Real cell boxes need + `NSTextTable`, which is TextKit 1 only. +- **The pointer does not change over the pane divider.** Dragging it resizes the + sidebar from either side, and `ResizeCursorAreaTests` shows the view asks for the + horizontal-resize cursor — but the hosted `NSView` carrying that request sits + inside the `accessibilityElement` that publishes `window.divider`, and the + collapse appears to detach it. Moving the request outside that element restores + the cursor and stops the drag reaching the strip, so the two want opposite + orderings and the resize wins. Unresolved; the likely answer is hanging the + cursor rect off a view that is not inside the accessibility element at all. +- **No find bar.** ⌘F does nothing. The text view is one document, so a find + interaction would work across the whole conversation; it simply is not turned + on. +- **⌘C does not copy from the conversation list.** It did, through a per-row + `.copyable`, but that cost more in scrolling than the shortcut was worth. Edit ▸ + Copy Link (⇧⌘C) and right click ▸ Copy Link both do the same thing. + +[RFD 099]: ../../docs/rfd/099-native-macos-app-for-browsing-conversations.md diff --git a/apps/macos/QA.md b/apps/macos/QA.md new file mode 100644 index 000000000..34b0eb838 --- /dev/null +++ b/apps/macos/QA.md @@ -0,0 +1,344 @@ +# QA checklist + +The behavior in [`AFFORDANCES.md`](AFFORDANCES.md) that has to be checked against +a running app. + +Each item says who checks it: + +- **`JPUITests/`** — a committed test in `apps/macos/UITests`. CI runs the + whole bundle with `just test-app-ui`; while writing one, run it by name + through the `swift_test_ui` tool. Each test launches the app and takes the + screen, so `just test-app` and the `swift_test` tool leave them out. + + The names below are a convenience, not the index: `swift_test_ui` asks the + built bundle what it holds, so that is the list to trust. +- **Eyes** — needs a person, permanently. Smoothness, rendering, and anything + whose answer is "does this look right". +- **Not yet mechanized** — checked by hand today, and a candidate for a test. +- **`debug_app_profile`** — answered by driving the app and reading back what it + timed, rather than by a committed test. These are the items about cost, and + they are checked in counts (view-body evaluations, FFI calls) rather than in + milliseconds: a count is the same for the same steps, so it can be compared + against an earlier run, while a millisecond threshold would be met by a broken + build on a quiet machine and missed by a good one on a busy machine. + +For everything still checked by hand, run `just run-app` first. It launches in +the foreground with output attached to the terminal, so warnings and crashes are +visible while you work through this. + +The hand-run items should be checked against a workspace with a few hundred +conversations, not an empty one: several of these only misbehave at size. The +UI tests build their own three-conversation workspace, which is why the ones +that only fail at size stay with a person. + +A suite shares one launched app across its tests, because launching costs +seconds and the work under test costs milliseconds. A test that needs an app +nobody has touched launches its own and says why; none currently does. + +`swift_test_ui` stops a run at the first failure and closes the app it was +driving, because a broken app usually fails every test after the first one too +and each costs a second to find that out. `just test-app-ui` sets `CI`, which +turns that off: nobody is watching a CI run, and one run reporting everything +beats a first failure reported quickly. + +## Launch and console + +- [x] The window opens showing the workspace named by `JP_WORKSPACE` — + `JPUITests` launches every test this way, so any test passing proves it. +- [ ] **No `reentrant operation in its NSTableView delegate` warning**, at + launch, on selection, or on quit. **Eyes**, or `debug_app_snapshot`: a UI + test cannot read the app's console, because `testmanagerd` launches the + app and keeps its output. `selectingKeepsTheInjectedMenuItems` covers the + damage that warning reports, but not the warning. +- [ ] No other warnings or exceptions in the terminal. **Eyes**, same reason. + +## Conversation list + +- [x] The window carries the workspace directory name as its title, and nothing + counting conversations beside it — + `ConversationListTests/namesTheWorkspace`. +- [ ] **Nothing displays that title, and there is no strip of chrome above the + transcript**: the window buttons sit over the sidebar's top-left corner with + the search field beside them, and there is no sidebar toggle button. + **Eyes**: the test above proves the title is carried, not that it is hidden. +- [x] View ▸ Hide Sidebar hides the conversation list and Show Sidebar brings it + back, which is the only way to now that the button is gone — + `ConversationListTests/viewMenuTogglesTheSidebar`. +- [ ] **⌃⌘S does the same as the menu item.** **Eyes**: the test above chooses + the item rather than pressing the key. +- [x] Conversations are ordered most recently active first — + `ConversationListTests/ordersByActivity`. +- [x] A pinned conversation sits above every unpinned one, whatever their + activity, and its row says it is pinned — + `PinnedConversationTests/pinnedSortsFirst`. +- [x] A row reads as its title and event count together — + `ConversationListTests/labelsRows`. +- [x] Typing in the filter box narrows the list, and the clear button beside it — + there whether or not anything has been typed — restores the whole list — + `ConversationListTests/filtersAndClears`. +- [ ] **The search field lines up with the window buttons**, its middle level + with theirs, and is about as tall as Bear's. **Eyes**: the field's text is an + accessibility element and is centred on the buttons, but the rounded box + around it is drawn and cannot be measured. +- [ ] **A pinned row shows the pin glyph in the accent colour**, to the left of + the date. **Eyes**: the test above proves the row is labelled pinned and + sorted first, not that anything is drawn. +- [ ] **A long title wraps to a second line and truncates there**, not after the + first. **Eyes**, or `debug_app_pixels`: the accessibility label carries the + whole title whatever is drawn, so the tree cannot see where it was cut, but a + scan down a row shows one text band or two. +- [ ] **The selected row carries a thick accent bar down its leading edge**, inside + the rounded fill rather than against the window's edge. **Eyes**, or a row + scan across the selection. +- [ ] **A line appears above the first row when the list is scrolled**, and goes + away at the top. **Eyes**. +- [ ] **The window buttons sit level with the search field**, about twenty points + in from the window's left edge. Their frames are in the accessibility tree, + so the centres can be compared against the field's without a screenshot; the + visible circles inside them cannot, and want a scan. +- [ ] **Dragging the divider is smooth** on a workspace of a thousand + conversations. **Eyes**: a synthesized drag needs the window frontmost, and + the cost is in SwiftUI's own re-evaluation rather than in anything the app + times. +- [ ] **A row's date reads the way the system locale writes one**: how long ago + for anything active today, `31 Jul` inside this year, `13 May 2024` before + that. **Eyes**: `ConversationDateTests` pins all three against a fixed + locale, which is not the reader's. +- [ ] **The list is the palette's, not the system's**: white rows on a white + sidebar in light appearance, `#1D1E20` in dark, separators inset to the + text rather than running edge to edge, and a selected row filled `#F4F5F7` + in an inset rounded rectangle. **Eyes**, in both appearances. `ThemeTests` + proves each colour resolves per appearance; only a screenshot says the list + is actually wearing them. +- [ ] **A selected row shows no trace of the system accent colour**, at the + moment of the click or after it. **Eyes**: the table view's selection + drawing is turned off through AppKit, and nothing outside the window can + see what a row is filled with. +- [ ] **The line between the sidebar and the transcript is one point of one + uniform colour**, the divider colour, top to bottom — two pixels on a retina + display. **Eyes**: the app draws this line itself, so its geometry is + checkable (the sidebar ends at 280 and the transcript starts at 281) but its + colour is not. +- [ ] **The pointer becomes the horizontal-resize cursor over that line.** + Currently it does not — see the Not implemented section of `AFFORDANCES.md`. + `ResizeCursorAreaTests` shows the view *asks* for the right cursor over the + right area, which is as far as a test reaches: a cursor is neither in the + accessibility tree nor in a screenshot. The test passing while the pointer + stays an arrow is the gap, and is why this line is unchecked. +- [x] **Dragging that line resizes the sidebar**, from either side of it — driven + against `window.divider` with `debug_app_drive`'s `drag` step, starting on the + right half, and the divider's frame moved by exactly the distance dragged. + Approaching from the transcript side used to do nothing at all, because the + pane is a later sibling and so in front of the grab strip. +- [ ] The sidebar stops at 220 and 480, and its width survives closing and + reopening the window. **Not yet mechanized**; the drag above reaches it now. +- [ ] **The search field is a rounded box with a magnifier inside it** and no + focus ring when it takes focus. **Eyes**. +- [ ] **The transcript sits on the editor background**, with prose in the body + colour and speaker names in the secondary one. **Eyes**, in both + appearances. +- [x] A single click selects that row, and the transcript follows — + `ConversationListTests/clickSelects`. +- [x] A click in the empty space beside the title selects the row too — + `ConversationListTests/clickBesideTitleSelects`. +- [x] ↑ and ↓ move the selection, and the transcript follows — + `ConversationListTests/arrowKeysMoveSelection`. +- [x] A double-click opens the conversation in a new window, and that window + shows the conversation rather than an empty pane — + `ConversationListTests/doubleClickOpensAWindow`. +- [x] Right-click ▸ Open in New Window does the same — + `ConversationListTests/contextMenuOpensAWindow`. +- [x] Right-click ▸ Copy Link puts `jp://` on the pasteboard — + `ConversationListTests/contextMenuCopiesTheURI`. +- [x] Escape clears the selection and empties the transcript pane — + `ConversationListTests/editCopyLinkFollowsTheSelection`. +- [x] Edit ▸ Copy Link (⇧⌘C) puts the selected conversation's URI on the + pasteboard, and is disabled while nothing is selected — + `ConversationListTests/editCopyLinkFollowsTheSelection`. +- [x] Selecting a conversation leaves the View and Window menus intact — Enter + Full Screen, Merge All Windows and the rest are items AppKit injects, and a + menu bar rebuilt at the wrong moment drops them — + `ConversationListTests/selectingKeepsTheInjectedMenuItems`. +- [ ] Dragging a row into a text editor inserts `jp://`. **Eyes**: the drop + target is another application, which is outside what a UI test can drive. +- [ ] **Scrolling the sidebar of a large workspace is smooth**, with no stutter + as rows come into view. **Eyes**. + +### Copy Link is checked without a clipboard being lost + +No test touches the *system* pasteboard. There is one of those and it belongs to +whoever is at the keyboard: a test that copies into it destroys what they had, +and saving and restoring around the test is not a fix, because a pasteboard item +can be a promise its owner fulfils lazily. + +So a debug build copies wherever `JP_DEBUG_PASTEBOARD` says, and each test +points the app at a private pasteboard of its own and reads that back. The +variable is compiled out of a release build, and an unset one means the system +pasteboard, so the shipped behaviour is the only behaviour a user can get. + +`ClipboardPolicyTests` scans `apps/macos/UITests` and fails on any spelling of +the system pasteboard, so this holds without anyone remembering it. + +### Multiple selection is not implemented + +`AFFORDANCES.md` says the context menu acts on the whole selection, so three +selected conversations copy three URIs and open three windows. The list binds a +single `String?`, so it never holds more than one conversation: a shift-click +does not extend the selection, and Copy Link on three rows copies one URI. + +No test asserts either behavior until this is settled, because one of the two +documents is wrong and it is not this checklist's job to pick. + +## Transcript + +- [x] Each message is shown under the name of whoever said it, and the whole + transcript is one text view rather than one view per message — + `ConversationListTests/clickSelects` compares the text view's whole value + against `Transcripts.configPipeline`, so a missing speaker label or a + duplicated message fails it. +- [x] Nothing but messages is shown: no tool calls, no reasoning, no turn + markers, no dimmed kind labels — the library never sends them. + `jp_ffi`'s `drops_every_event_with_no_prose_to_show` holds the boundary and + `ConversationTurnDecodingTests` holds the mirror. +- [ ] Block markdown looks right: heading sizes, list markers hanging outside + their text, wrapped list lines aligning under the first rather than under + the bullet, code blocks on their own background, quotes indented and dimmed. + **Eyes**: `MarkdownTests` pins every one of these as attributes, and none of + that says the result is legible. +- [ ] Paragraph spacing reads as paragraphs, and the gap between two turns is + clearly wider than the gap between two messages inside one. **Eyes**: the + numbers are pinned, the impression is not. +- [ ] A table reads as a table: columns line up down the rows, the header is + bold, and a column declared right-aligned has its numbers ending together. + **Eyes**: `MarkdownTests` pins the tab stops and their alignments, and none + of that says the columns look aligned. A cell longer than the fixed column + width will run into the next column — known, see `AFFORDANCES.md`. +- [ ] Text can be selected across message boundaries and copied with ⌘C. **Not + yet mechanized**; selection across the whole document is the point of one + text view, so a selection that stops at a message is a defect. +- [ ] **Scrolling a long conversation is smooth, and the scroll bar keeps a + constant size** rather than resizing or jumping as you scroll. **Eyes**. + Contiguous TextKit 1 layout is what makes the height exact, so a shifting + knob here means something changed about that — see `AFFORDANCES.md`. +- [x] **Dragging a window edge re-wraps the text as it moves, at any scroll + position** rather than waiting for the mouse to come up — + `TranscriptReflowTests/reflowsWhileDragging`, which drags a real window edge + and asserts the text container's width changed during the gesture. Verified + red by disabling the container write. +- [ ] **Resizing stays smooth on a long conversation.** Measure with + `debug_app_drive` using `reads: "none"` and a profile bracket, and compare + counts against another recording; a run with tree reads on measures the + reads instead. A resize evaluates no SwiftUI view bodies, so a climb here is + layout or text measurement, not re-rendering. + +## Performance + +Most of this is **eyes**: how the app feels under a load the fixture workspace +does not have, and no threshold in milliseconds separates a good build from a bad +one across machines. + +What is checkable is the work the app does rather than the time it takes. +`debug_app_drive` records when each step ran and `debug_app_profile` with +`mode: "report"` attributes the app's own intervals to those steps, so "does the +third selection cost more than the first" has an answer that does not depend on +the machine. + +- [ ] Selecting a conversation renders it **without a visible spinner** for a + conversation of a hundred events or so. **Eyes**. +- [ ] A conversation of a couple of thousand events opens without a stall. + **Eyes**. +- [ ] Selecting several conversations in a row stays responsive; the second and + third selections are not slower than the first. + **`debug_app_profile`**: drive five selections, then `mode: "report"`. The + `View bodies` and `FFI calls` columns should stay flat down the table. A + column that climbs is re-evaluation rather than loading, and the report + says so. +- [ ] Revisiting conversations does not cost memory a second time. + **`debug_app_profile`**: drive the same two conversations alternately six + times and read the `Footprint` column. It should plateau, because the climb + on first visit is the allocator's high-water mark rather than retention. + A column that keeps climbing while the same two conversations are + re-selected is a leak. +- [ ] **Switching conversations does not flash an empty pane**: the previous + transcript stays until the next one replaces it. **Eyes**. + +## Windows and tabs + +All **not yet mechanized**. `XCUIApplication.windows` counts and titles reach +most of this. + +- [ ] ⌘N opens a window on the workspace you were last reading. +- [ ] ⌘W closes the frontmost window or tab. +- [ ] Closing the last window and pressing ⌘N puts you back in the same + workspace. +- [ ] ⌘T opens a new tab. +- [ ] Window ▸ Merge All Windows collects windows into tabs. +- [ ] A tab can be dragged out into its own window. **Eyes**: a tear-off drag + has no accessibility action behind it. +- [ ] Opening the same workspace twice — once via ⌘O, once via Open Recent — + brings the existing window forward rather than opening a second one. + +## Open and Open Recent + +All **not yet mechanized**. The recents list is already isolated per test by +`JP_DEBUG_STATE_DIR`, so even Clear Menu is safe to drive. + +- [ ] ⌘O offers a directory chooser that only allows directories. +- [ ] Choosing a directory *inside* a workspace opens that workspace. +- [ ] Choosing a directory that is not in any workspace shows a readable message + rather than an empty list. +- [ ] The chosen workspace appears at the top of File ▸ Open Recent. +- [ ] A workspace whose directory has been deleted disappears from the menu on + the next launch. +- [ ] Open Recent ▸ Clear empties the menu and disables it. + +## State restoration + +Quit with ⌘Q and relaunch for each of these. + +All **not yet mechanized**. `XCUIApplication.terminate()` and `.launch()` are +the natural fit, but every UI test today launches with +`-ApplePersistenceIgnoreState` so it neither restores nor saves window state; +these need that turned off, and with it a way to keep a run out of the +developer's own saved state — the same problem the pasteboard has, and it may +well have the same answer. + +- [ ] The window reopens on the same workspace. +- [ ] The conversation that was selected is selected again. +- [ ] The transcript is scrolled roughly where it was left. +- [ ] The sidebar keeps the width it was dragged to. **Eyes**: see the Not + implemented section of `AFFORDANCES.md`. +- [ ] A conversation window opened on its own reopens showing its conversation. + +## Accessibility + +With VoiceOver on (⌘F5). All **eyes**: what VoiceOver announces is not what the +accessibility tree holds, and only a person hears the difference. + +- [ ] The sidebar announces itself as `Conversations`. +- [ ] Each row is read as one item, with its title and event count together. +- [ ] Messages in the transcript are read as text. + +Against the identifier table in `AFFORDANCES.md`: + +- [x] A row's identifier is the conversation ID, and does not change when the + conversation is retitled — every `ConversationListTests` case addresses + rows by ID, and `AccessibilityIDTests` pins the shape. +- [x] The transcript publishes one text area named `transcript.text`, whose value + is every message it is showing — `ConversationListTests/clickSelects` reads + that value and compares it whole. There is deliberately no element per + message; see the Accessibility section of `AFFORDANCES.md`. +- [x] `sidebar.filter` and `sidebar.filter.clear` are both reachable while the + filter is narrowing the list — `ConversationListTests/filtersAndClears`. +- [ ] Every other identifier in the table is reachable while its state is on + screen. **Not yet mechanized**: the three empty states have no test driving + them into view. + +## Known gaps + +Not defects; see the Not implemented section of `AFFORDANCES.md`. + +- Dragging into Finder produces nothing useful. +- Nothing accepts a dropped conversation. +- New turns written by a concurrent `jp query` do not appear until the workspace + is reopened. diff --git a/apps/macos/Sources/AccessibilityID.swift b/apps/macos/Sources/AccessibilityID.swift new file mode 100644 index 000000000..41052825e --- /dev/null +++ b/apps/macos/Sources/AccessibilityID.swift @@ -0,0 +1,79 @@ +/// Stable names for the elements an external accessibility driver has to find. +/// +/// Every identifier is derived from identity, never from display text: renaming +/// a conversation, rewording an empty state, or localizing the app leaves all of +/// them unchanged. None of these are read out to a person. +/// +/// The state identifiers are what lets a driver wait on a predicate instead of +/// sleeping — `sidebar.state.loading` disappearing and ``Sidebar/list`` +/// appearing is the load completing. +enum AccessibilityID { + /// The conversation list, and the two things that stand in for it. + enum Sidebar { + /// The conversation list. + /// + /// Exists only once the workspace has been read, so this is also the + /// "sidebar loaded" predicate. There is no separate + /// `sidebar.state.loaded`: the loaded sidebar is a single element, and a + /// view carries one identifier. + static let list = "sidebar.list" + + /// The box that narrows the list to matching conversations. + static let filter = "sidebar.filter" + + /// The button that empties the filter box. + /// + /// Always present, whether or not the box holds anything, so it is not a + /// predicate for the filter being in use. + static let filterClear = "sidebar.filter.clear" + + /// The spinner shown while the workspace is being read. + static let loadingState = "sidebar.state.loading" + + /// The message shown when a filter matches none of the conversations. + /// + /// Distinct from ``unavailableState``: the workspace was read and does + /// hold conversations, so this says the query is wrong rather than that + /// there is nothing to show. + static let noMatchesState = "sidebar.state.nomatches" + + /// The message shown when there is no list to show, and why. + static let unavailableState = "sidebar.state.unavailable" + + /// One row, named by the conversation it shows. + static func row(_ conversation: ConversationSummary.ID) -> String { + "sidebar.row.\(conversation)" + } + } + + /// The strip between the two panes that resizes the sidebar. + /// + /// Named because it is the one thing in the window a driver can only reach by + /// dragging: the sidebar's width is not settable through the accessibility + /// tree. Wider than the line it draws, so a pointer can hit it. + static let paneDivider = "window.divider" + + /// The transcript pane and its contents. + enum Transcript { + /// The scrolling transcript. + /// + /// Exists only once a conversation has been read, so this is also the + /// "transcript loaded" predicate, for the same reason as + /// ``Sidebar/list``. + static let scroll = "transcript.scroll" + + /// The spinner shown while a conversation is being read. + static let loadingState = "transcript.state.loading" + + /// The message shown when there is no transcript, covering both no + /// selection and a conversation that could not be read. + static let unavailableState = "transcript.state.unavailable" + + /// The text the transcript is drawn as. + /// + /// The whole conversation is one text view, so there is no element per + /// message to name. A driver addresses the transcript by this and reads + /// its value, which is every message it is showing. + static let text = "transcript.text" + } +} diff --git a/apps/macos/Sources/Bridging/JPFFI-Bridging-Header.h b/apps/macos/Sources/Bridging/JPFFI-Bridging-Header.h new file mode 100644 index 000000000..a5f64d6cc --- /dev/null +++ b/apps/macos/Sources/Bridging/JPFFI-Bridging-Header.h @@ -0,0 +1,6 @@ +// Exposes the `jp_ffi` C entry points to Swift. +// +// `jp_ffi.h` is generated by `just build-ffi` into the cargo target directory, +// which the target's HEADER_SEARCH_PATHS points at. + +#import "jp_ffi.h" diff --git a/apps/macos/Sources/ConversationDate.swift b/apps/macos/Sources/ConversationDate.swift new file mode 100644 index 000000000..054aea25b --- /dev/null +++ b/apps/macos/Sources/ConversationDate.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Turns the timestamps the library reports into the dates a row shows. +/// +/// Pure, and given its reference instant rather than reading a clock, so what a +/// row reads at any moment can be pinned without a running app. +enum ConversationDate { + /// The instant `text` names, or `nil` if it is not a timestamp the library + /// emits. + /// + /// Two shapes are accepted because the library emits both: it keeps whatever + /// sub-second precision a conversation was stored with, so a conversation JP + /// created from a wall clock carries a fractional-seconds part and one + /// written by hand usually does not. + static func parse(_ text: String) -> Date? { + if let date = try? whole.parse(text) { + return date + } + + return try? fractional.parse(text) + } + + /// How a row dates `conversation`, or `nil` if its timestamp will not parse. + /// + /// A row that cannot date itself shows no date rather than showing a + /// placeholder: the date is a convenience beside the title, and a row of + /// error text where a person expects "31 Jul" is worse than a gap. + static func activityLabel( + for conversation: ConversationSummary, + now: Date, + calendar: Calendar = .current, + locale: Locale = .current + ) -> String? { + guard let date = parse(conversation.lastActivatedAt) else { return nil } + + return label(for: date, now: now, calendar: calendar, locale: locale) + } + + /// How a row labels `date`. + /// + /// Three forms, by how far back it is: how long ago on the day it happened + /// ("21 minutes ago"), the day and month inside the same year ("31 Jul"), + /// and the year as well before that ("13 May 2024"). + /// + /// The order of day and month is the locale's, so a reader gets the one they + /// expect. + static func label( + for date: Date, + now: Date, + calendar: Calendar = .current, + locale: Locale = .current + ) -> String { + if calendar.isDate(date, inSameDayAs: now) { + return elapsed(from: date, to: now) + } + + let dayAndMonth = Date.FormatStyle( + locale: locale, + calendar: calendar, + timeZone: calendar.timeZone + ) + .day().month(.abbreviated) + + guard + calendar.component(.year, from: date) == calendar.component(.year, from: now) + else { + return date.formatted(dayAndMonth.year()) + } + + return date.formatted(dayAndMonth) + } + + /// How long before `now` the conversation was active, in the largest unit + /// that gives a whole number. + /// + /// Only ever called for two instants on the same day, so hours is the + /// coarsest unit it needs. Anything under a minute, and anything a clock + /// adjustment has put in the future, reads as just now. + private static func elapsed(from date: Date, to now: Date) -> String { + let seconds = Int(now.timeIntervalSince(date)) + guard seconds >= 60 else { return "just now" } + + let minutes = seconds / 60 + guard minutes >= 60 else { + return minutes == 1 ? "1 minute ago" : "\(minutes) minutes ago" + } + + let hours = minutes / 60 + return hours == 1 ? "1 hour ago" : "\(hours) hours ago" + } + + /// Parses `2024-09-02T12:30:00Z`. + /// + /// A format style rather than an `ISO8601DateFormatter`, because this is a + /// `Sendable` value and the formatter is a reference type that cannot be + /// held in a `static let` under strict concurrency checking. + private static let whole = Date.ISO8601FormatStyle(includingFractionalSeconds: false) + + /// Parses `2024-09-02T12:30:00.123456Z`. + private static let fractional = Date.ISO8601FormatStyle(includingFractionalSeconds: true) +} diff --git a/apps/macos/Sources/ConversationEvent.swift b/apps/macos/Sources/ConversationEvent.swift new file mode 100644 index 000000000..bb57bc951 --- /dev/null +++ b/apps/macos/Sources/ConversationEvent.swift @@ -0,0 +1,91 @@ +import Foundation + +/// One event in a conversation, as `jp_workspace_events` presents it. +/// +/// Hand-maintained to match `DisplayEvent` in the Rust `jp_ffi` crate. The `type` +/// tag names the *presentation*, not the stored event kind, so nothing here +/// decides what a `chat_request` means — that judgement is about the conversation +/// model and lives with the model. +/// +/// Only messages reach this side. Tool calls, reasoning, inquiries and config +/// changes have no prose to show and the library leaves them out. +enum ConversationEvent: Decodable, Sendable, Equatable { + /// A message the user sent, with the display name of whoever wrote it. + case userMessage(timestamp: String, author: String?, text: String) + + /// A message the assistant replied with. + case assistantMessage(timestamp: String, text: String) + + /// A presentation this build has no way to draw. + /// + /// Thrown rather than absorbed into a catch-all case, so the decision to + /// skip it belongs to whoever is decoding a whole turn rather than being + /// made silently here. A malformed event of a *known* presentation still + /// fails, which is what keeps a wire-format mistake visible. + struct UnknownPresentation: Error { + let type: String + } + + /// When the event was recorded, as RFC 3339 text. + /// + /// Kept unparsed because nothing displays it yet. Every timestamp the library + /// reports uses this one format, so one decoder will cover events and + /// conversation summaries alike when something needs it. + var timestamp: String { + switch self { + case .userMessage(let timestamp, _, _), + .assistantMessage(let timestamp, _): + timestamp + } + } + + /// What the event has to say. + var text: String { + switch self { + case .userMessage(_, _, let text), + .assistantMessage(_, let text): + text + } + } + + /// Who said it, as it should be shown above the message. + /// + /// A user message with no recorded author was written before a display name + /// was configured, and is still theirs. + var speaker: String { + switch self { + case .userMessage(_, let author, _): author ?? "You" + case .assistantMessage: "Assistant" + } + } + + private enum CodingKeys: String, CodingKey { + case type + case timestamp + case author + case text + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let timestamp = try container.decode(String.self, forKey: .timestamp) + + switch try container.decode(String.self, forKey: .type) { + case "user_message": + self = .userMessage( + timestamp: timestamp, + author: try container.decodeIfPresent(String.self, forKey: .author), + text: try container.decode(String.self, forKey: .text) + ) + + case "assistant_message": + self = .assistantMessage( + timestamp: timestamp, + text: try container.decode(String.self, forKey: .text) + ) + + case let type: + throw UnknownPresentation(type: type) + } + } +} diff --git a/apps/macos/Sources/ConversationFilter.swift b/apps/macos/Sources/ConversationFilter.swift new file mode 100644 index 000000000..63b4b8adc --- /dev/null +++ b/apps/macos/Sources/ConversationFilter.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Narrows the conversation list to what a person typed. +/// +/// Pure, so the matching rules can be pinned without a window: what counts as a +/// match is a product decision, and the place it is decided should not need a +/// running app to inspect. +enum ConversationFilter { + /// The conversations whose title contains `query`. + /// + /// A blank query matches everything, so clearing the box restores the list + /// rather than emptying it. + /// + /// Matching is on the title as the row displays it, including the placeholder + /// an untitled conversation shows: filtering a list means filtering what is on + /// screen, and a row a person can read but not search for is a surprise. + /// Conversation IDs are deliberately not searched — they are timestamps, and + /// matching them would let a query hit rows with no visible reason. + /// + /// Order is preserved, so the list stays most recently active first. + static func matches( + _ conversations: [ConversationSummary], query: String + ) + -> [ConversationSummary] + { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return conversations } + + return conversations.filter { displayTitle(of: $0).localizedStandardContains(query) } + } + + /// The title a row shows for a conversation. + /// + /// Untitled conversations are common — a title is generated after the first + /// turn — so the placeholder is part of what the list displays and part of + /// what a query searches. + static func displayTitle(of conversation: ConversationSummary) -> String { + return conversation.title ?? "Untitled" + } +} diff --git a/apps/macos/Sources/ConversationHistoryView.swift b/apps/macos/Sources/ConversationHistoryView.swift new file mode 100644 index 000000000..cd7763d4b --- /dev/null +++ b/apps/macos/Sources/ConversationHistoryView.swift @@ -0,0 +1,160 @@ +import SwiftUI + +/// What the history pane has to show. +/// +/// One value rather than separate properties, for the same reason as +/// ``WorkspaceState``: a load result reaches the view in a single mutation. +enum TranscriptState: Equatable, Sendable { + /// A conversation is being read. + case loading + + /// A conversation's turns, oldest first, ready to draw, and which + /// conversation they came from. + /// + /// The identifier travels with the turns rather than being read from the + /// view, because the two disagree for as long as a newly selected + /// conversation is still being read — the pane goes on showing the last one. + case loaded(id: String, turns: [ConversationTurn]) + + /// There is nothing to show, and why. + case unavailable(title: String, detail: String) + + /// Whether there is a transcript on screen worth keeping while another + /// loads. + var hasContent: Bool { + if case .loaded = self { true } else { false } + } +} + +/// The selected conversation, rendered as a scrolling transcript. +struct ConversationHistoryView: View { + let model: WorkspaceModel + let conversationID: ConversationSummary.ID? + + @State private var state: TranscriptState = .unavailable( + title: "No Conversation Selected", + detail: "Pick a conversation to read it." + ) + + var body: some View { + Trace.measuring("ConversationHistoryView.body", target: Self.traceTarget) { + content + } + } + + /// What the pane shows, timed by ``body``. + private var content: some View { + Group { + switch state { + case .loading: + ProgressView() + .controlSize(.small) + .accessibilityLabel("Loading conversation") + .accessibilityIdentifier(AccessibilityID.Transcript.loadingState) + + case .loaded(let id, let turns): + TranscriptTextView(conversationID: id, turns: turns) + // One text view per conversation, so switching builds a new + // one rather than moving a new transcript into the old one. + // Sharing it kept the scroll offset across a switch, because + // nothing told it the content underneath had been replaced. + // + // Keyed on the conversation *on screen*, not the one + // selected. Keyed on the selection it changed the moment a + // row was clicked, which built a second text view around the + // outgoing transcript and paid for the whole document again + // before the new one had even been read. + .id(id) + + case .unavailable(let title, let detail): + ContentUnavailableView( + title, + systemImage: "bubble.left.and.text.bubble.right", + description: Text(detail) + ) + .accessibilityIdentifier(AccessibilityID.Transcript.unavailableState) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Theme.editorBackground.color) + // Keyed on the *open* workspace, not the requested one. A window opened + // straight onto a conversation renders while its workspace is still + // opening, and keying on the request would fire that first read against + // no session and never try again. + .task(id: ReadKey(workspace: model.openWorkspace, conversation: conversationID)) { + await load() + } + } + + /// What this pane's events are attributed to. + private static let traceTarget = "JP.Transcript" + + /// The interval covering one conversation being picked and read. + /// + /// Named once because the read nested inside it reports it as its enclosing + /// span, and two spellings would break that link. + /// + /// Covers the read alone. Building the text and laying it out happen later, + /// while the view draws, and are timed there as `transcript.render`. + private static let selectionSpan = "conversation.select" + + /// What a reload depends on. + private struct ReadKey: Equatable { + let workspace: String? + let conversation: String? + } + + private func load() async { + guard let conversationID else { + state = .unavailable( + title: "No Conversation Selected", + detail: "Pick a conversation to read it." + ) + return + } + + // Nothing to read until the workspace is open; the task runs again when + // it is. + guard model.openWorkspace != nil else { return } + + // The transcript already on screen stays there until the next one is + // ready. Clearing first put an empty pane between the two, which reads as + // a flash when the read takes a few milliseconds. Only an empty pane gets + // a spinner, because there is nothing to keep. + if !state.hasContent { + state = .loading + } + + let timing = Trace.interval(Self.selectionSpan, target: Self.traceTarget) + let result = await model.events(for: conversationID, spans: [Self.selectionSpan]) + + // Selecting another conversation cancels this task, but the read it + // started still finishes, and its result must not replace the new one. + guard !Task.isCancelled else { + timing.end([("cancelled", true)]) + return + } + + let next: TranscriptState = + switch result { + case .success(let turns) where turns.isEmpty: + .unavailable( + title: "Empty Conversation", + detail: "This conversation has no messages yet." + ) + case .success(let turns): + .loaded(id: conversationID, turns: turns) + case .failure(let error): + .unavailable(title: "Could Not Read Conversation", detail: error.message) + } + + // Animated at the assignment rather than through `.animation(value:)`, + // which would compare the whole transcript — every message — on each + // change to decide whether to animate. + withAnimation(DebugState.animated(.easeInOut(duration: 0.12))) { + state = next + } + + timing.end() + } +} diff --git a/apps/macos/Sources/ConversationList.swift b/apps/macos/Sources/ConversationList.swift new file mode 100644 index 000000000..49419b8b8 --- /dev/null +++ b/apps/macos/Sources/ConversationList.swift @@ -0,0 +1,132 @@ +import SwiftUI + +/// The conversation list, as a view of its own so that resizing the sidebar does +/// not re-render it. +/// +/// A `List` of a thousand rows is expensive to evaluate, and dragging the divider +/// changes the sidebar's width on every frame of the drag. Built inline in the +/// window's body, the whole list was rebuilt each of those frames and the drag +/// felt heavy. As a separate view compared by its data, SwiftUI finds its inputs +/// unchanged and skips it: the width applies to the frame around it, which costs +/// nothing. +/// +/// Equality is by data alone, ignoring the closures and the binding. Those never +/// compare equal, and a value carrying them would differ on every comparison — +/// which is the thing this exists to prevent. ``WorkspaceActions`` makes the same +/// trade for the same reason. +struct ConversationList: View, Equatable { + /// The conversations to show, in the order they appear. + let matches: [ConversationSummary] + + /// The rows that draw no line under them. + let separatorless: Set + + /// The instant the rows date their conversations against. + /// + /// Passed in rather than read here, because a fresh `Date()` per render would + /// make every comparison unequal and defeat the skipping this view is for. + let now: Date + + /// Which conversation is selected. + /// + /// The same value the binding below carries, held separately because equality + /// has to see it: a `Binding` is read through a closure, which a `nonisolated` + /// comparison cannot do, and a comparison that ignored the selection would + /// leave the highlight on the row it was last drawn on. + let selectedID: ConversationSummary.ID? + + /// The selected conversation, for the list to write as it is clicked through. + @Binding var selection: ConversationSummary.ID? + + /// The `jp://` reference for a conversation, for dragging it out. + let reference: (ConversationSummary) -> ConversationRef + + /// Open each named conversation in a window of its own. + let openWindows: (Set) -> Void + + /// Put each named conversation's URI on the pasteboard. + let copyLinks: (Set) -> Void + + /// Called when the list leaves the top of its content, or returns to it. + let scrolledAwayFromTop: (Bool) -> Void + + /// `nonisolated` because `Equatable` is: a `View` is main-actor isolated and its + /// members inherit that, which a protocol requirement declared without + /// isolation cannot satisfy. Safe, because everything compared is plain data. + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.matches == rhs.matches + && lhs.separatorless == rhs.separatorless + && lhs.now == rhs.now + && lhs.selectedID == rhs.selectedID + } + + var body: some View { + List(matches, selection: $selection) { conversation in + ConversationRow( + conversation: conversation, + isSelected: selectedID == conversation.id, + drawsSeparator: !separatorless.contains(conversation.id), + now: now + ) + .draggable(reference(conversation)) + // Silences the table view's own selection fill, which is the system + // accent colour. Placed in the row because that is where it can reach + // the table view; see ``ListSelectionHighlight``. + .background(ListSelectionHighlight.removed) + // The row fills its cell edge to edge and draws its own background, + // selection and separator. Everything the list would otherwise + // contribute is turned off here: its separators run to both edges, and + // its selection is the system accent colour. + // + // Asking for no insets does not get none. A plain list keeps eight + // points at the leading edge whatever this says, which is the gap a + // reader sees beside the selection. + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + .listStyle(.plain) + // Mapped to a `Bool` rather than watched as an offset: the action then runs + // when the answer changes rather than on every frame of a scroll. + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y > 0 + } action: { _, scrolled in + scrolledAwayFromTop(scrolled) + } + // Uncovers the `.background` below. Without it the list draws the system's + // own list background over the sidebar's colour. + .scrollContentBackground(.hidden) + .background(Theme.sidebarBackground.color) + .accessibilityLabel("Conversations") + .accessibilityIdentifier(AccessibilityID.Sidebar.list) + // Escape clears the selection and empties the detail pane. Reaching a + // workspace with nothing selected is otherwise only possible by opening + // one, which makes "no conversation chosen" a state the app can enter and + // never return to. + // + // On the list rather than on the window, so Escape in the filter field + // still means "clear what I typed". + .onExitCommand { selection = nil } + // One menu for the list rather than one per row. A per-row `contextMenu` is + // built for every row the list realizes, which a sidebar of a thousand + // conversations pays for on every scroll. + // + // `primaryAction` is also how a double-click is meant to be handled here: a + // tap gesture on a row competes with the click the list uses to move the + // selection. + // + // Both act on the full list, not the visible one: an identifier that came + // from a row is valid whether or not the filter still shows it. + .contextMenu(forSelectionType: ConversationSummary.ID.self) { ids in + // These carry no accessibility identifier because they cannot. SwiftUI + // bridges a menu button to an `NSMenuItem` and does not carry the + // modifier across, on the button or on its label, so both items report + // the selector name `menuAction:`. A driver addresses them by title. + Button("Open in New Window") { openWindows(ids) } + Divider() + Button("Copy Link") { copyLinks(ids) } + } primaryAction: { ids in + openWindows(ids) + } + } +} diff --git a/apps/macos/Sources/ConversationOrder.swift b/apps/macos/Sources/ConversationOrder.swift new file mode 100644 index 000000000..bf9814ff3 --- /dev/null +++ b/apps/macos/Sources/ConversationOrder.swift @@ -0,0 +1,42 @@ +/// Puts the conversation list in the order the sidebar shows it. +/// +/// Pure, and separate from the library's own ordering on purpose: the library +/// reports conversations most recently active first, which is a fact about the +/// data, and where a pinned conversation belongs in a list is a decision about +/// the interface. +enum ConversationOrder { + /// `conversations` with the pinned ones first. + /// + /// A stable partition: inside each group the given order is kept, so pinning + /// a conversation lifts it to the top and moves nothing else. Pinned + /// conversations stay most recently active first among themselves. + static func pinnedFirst(_ conversations: [ConversationSummary]) -> [ConversationSummary] { + let pinned = conversations.filter(\.isPinned) + + // The common case, and worth the check: this runs on every keystroke in + // the filter box, over every conversation the workspace holds. + guard !pinned.isEmpty else { return conversations } + + return pinned + conversations.filter { !$0.isPinned } + } + + /// The rows that draw no line under them, given what is selected. + /// + /// The selected row and the one above it, so the selection's rounded fill is + /// not cut across by a separator at either end of it. Empty when nothing is + /// selected, and when the selection is not in the list — which happens while a + /// filter is hiding the selected conversation. + static func rowsWithoutSeparator( + in conversations: [ConversationSummary], + selecting selection: ConversationSummary.ID? + ) -> Set { + guard + let selection, + let index = conversations.firstIndex(where: { $0.id == selection }) + else { return [] } + + guard index > conversations.startIndex else { return [selection] } + + return [selection, conversations[index - 1].id] + } +} diff --git a/apps/macos/Sources/ConversationRef.swift b/apps/macos/Sources/ConversationRef.swift new file mode 100644 index 000000000..d7b087f84 --- /dev/null +++ b/apps/macos/Sources/ConversationRef.swift @@ -0,0 +1,40 @@ +import CoreTransferable +import Foundation + +/// A conversation, identified well enough to reopen from anywhere. +/// +/// Carries the workspace path as well as the ID so a value copied, dragged, or +/// restored into a new window can be read without a window already having that +/// workspace open. +struct ConversationRef: Codable, Hashable, Sendable { + let workspacePath: String + let conversationID: String + + /// The title to show for the conversation, when one is known. + /// + /// Cosmetic, and absent on a value restored from disk, so nothing depends on + /// it being present. + var title: String? + + /// A window title that says something even when the title is unknown. + var displayTitle: String { + title ?? "Conversation \(conversationID)" + } +} + +extension ConversationRef: Transferable { + /// How the conversation crosses a drag or a copy. + /// + /// Text, deliberately: a `jp://` URI is the form JP itself uses to reference + /// a conversation, so a paste into a terminal, an editor, or a query is + /// useful rather than opaque. A private binary type would only be readable by + /// this app, which has nowhere to drop one yet. + static var transferRepresentation: some TransferRepresentation { + ProxyRepresentation(exporting: \.uri) + } + + /// The conversation as a `jp://` URI. + var uri: String { + "jp://\(conversationID)" + } +} diff --git a/apps/macos/Sources/ConversationRow.swift b/apps/macos/Sources/ConversationRow.swift new file mode 100644 index 000000000..5c88cd32f --- /dev/null +++ b/apps/macos/Sources/ConversationRow.swift @@ -0,0 +1,175 @@ +import SwiftUI + +/// One conversation in the sidebar. +struct ConversationRow: View { + /// The height every row is laid out at. + /// + /// Fixed, not measured. A list has to know its total content height to size + /// its scroll bar, and with variable-height rows that means measuring every + /// row rather than the visible ones — a cost that grows with the number of + /// conversations. A uniform height lets it multiply instead. + /// + /// Sized for two lines of title over one of metadata, which is the tallest a + /// row gets. A title of one line leaves the rest of the space empty rather + /// than closing the gap, so the metadata sits on the same baseline in every + /// row. A larger system text size would clip it; a row that grows with the + /// text needs the list to supply the height some other way. + static let height: CGFloat = 72 + + /// How far the text sits in from the row's own leading edge. + private static let textInset: CGFloat = 16 + + /// How wide the bar marking the selected row is. + private static let accentBarWidth: CGFloat = 5 + + private static let selectionRadius: CGFloat = 6 + + let conversation: ConversationSummary + + /// Whether this is the selected conversation. + /// + /// The row draws its own selection rather than letting the list draw one; see + /// ``ListSelectionHighlight`` for why it has to. + let isSelected: Bool + + /// Whether to draw the line under the row. + /// + /// False for the selected row and the one above it, so no separator cuts + /// across either end of the selection's rounded fill. + let drawsSeparator: Bool + + /// The instant the row dates the conversation against. + /// + /// Passed in rather than read here, so one clock read covers a whole render + /// of the list instead of one per realized row. + let now: Date + + var body: some View { + ZStack { + Theme.sidebarBackground.color + + if isSelected { + selection + } + + text + } + .frame(height: Self.height) + .overlay(alignment: .bottom) { + if drawsSeparator { + separator + } + } + // Labelled explicitly, and children ignored rather than combined: + // combining walks and merges each row's accessibility subtree, which a + // sidebar of a thousand rows pays for as it scrolls. + .accessibilityElement(children: .ignore) + .accessibilityLabel(label) + // Safe on the same view as the `.ignore` above: that collapses the row + // to one leaf element, and this names it. A row is addressed by the + // conversation's ID, so retitling one does not move it. + .accessibilityIdentifier(AccessibilityID.Sidebar.row(conversation.id)) + } + + /// The title, and the metadata under it. + private var text: some View { + // No spacing and no spacer between the two. A `Spacer` here is charged the + // stack's spacing twice, once on each side of it, and those eight points + // are the difference between a row that fits two lines of title and one + // that fits one: the title then takes a single line and truncates however + // high its line limit is. The title claims the leftover height instead, + // which holds the metadata to the bottom just as well. + VStack(alignment: .leading, spacing: 0) { + Text(verbatim: title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.bodyText.color) + .lineLimit(2) + .frame(maxHeight: .infinity, alignment: .topLeading) + + metadata + } + // Inside the padding, not around it. Outside, the stack keeps its ideal + // width and the title is offered as much room as it asks for: it then + // never wraps, and the row clips it into an ellipsis instead. Inside, the + // stack is handed the row's width and a long title wraps to its second + // line as intended. + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.horizontal, Self.textInset) + .padding(.vertical, 10) + } + + /// The pin, the date and the event count, under the title. + private var metadata: some View { + HStack(spacing: 5) { + if conversation.isPinned { + // Rotated because SF Symbols draws a pin upright and this one + // reads as pinning something to a board. + Image(systemName: "pin.fill") + .rotationEffect(.degrees(45)) + .foregroundStyle(Theme.accent.color) + } + + if let date = ConversationDate.activityLabel(for: conversation, now: now) { + Text(verbatim: date) + Text(verbatim: "·") + } + + Text(verbatim: eventCount) + } + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText.color) + } + + /// The line under the row. + /// + /// Drawn by the row rather than by the list, for two reasons: + /// `listRowSeparatorTint` leaves a plain list's separators the system colour + /// on macOS, and the list draws them edge to edge. + private var separator: some View { + Rectangle() + .fill(Theme.rowSeparator.color) + .frame(height: 1) + } + + /// What fills the selected row. + /// + /// The accent bar belongs to the fill rather than to the row, and is clipped + /// to the same rounded rectangle: against the row's edge it would run the + /// window's full height and square off the corners the fill has. + private var selection: some View { + RoundedRectangle(cornerRadius: Self.selectionRadius) + .fill(Theme.selectedRowBackground.color) + .overlay(alignment: .leading) { + Rectangle() + .fill(Theme.accent.color) + .frame(width: Self.accentBarWidth) + } + .clipShape(RoundedRectangle(cornerRadius: Self.selectionRadius)) + } + + /// What a screen reader announces for the row. + /// + /// The date is deliberately left out. It is relative for anything active + /// today, so a label carrying it would say something different one minute + /// later and could not be pinned by a test. + private var label: String { + let pinned = conversation.isPinned ? ", pinned" : "" + return "\(title), \(eventCount)\(pinned)" + } + + /// Shared with the filter, so a row can always be found by the words it + /// shows. Two placeholders that drifted apart would make untitled + /// conversations visible but unsearchable. + private var title: String { + ConversationFilter.displayTitle(of: conversation) + } + + /// Pluralized by hand, and `verbatim` so neither this nor the title goes + /// through a localization lookup. + /// + /// `^[\(count) event](inflect: true)` reads better but resolves grammatical + /// agreement at runtime, once per row, every time the list realizes one. + private var eventCount: String { + conversation.eventsCount == 1 ? "1 event" : "\(conversation.eventsCount) events" + } +} diff --git a/apps/macos/Sources/ConversationTurn.swift b/apps/macos/Sources/ConversationTurn.swift new file mode 100644 index 000000000..8a6f4fe4b --- /dev/null +++ b/apps/macos/Sources/ConversationTurn.swift @@ -0,0 +1,61 @@ +import Foundation + +/// One turn of a conversation, as `jp_workspace_events` presents it. +/// +/// Hand-maintained to match `DisplayTurn` in the Rust `jp_ffi` crate. A turn is +/// one user request through the assistant's final answer to it, and where its +/// boundaries fall is decided on the library side: the rules involve an +/// implicit leading turn and a marker that opens a turn only sometimes, neither +/// of which is recoverable from the events alone. +/// +/// A turn the library had nothing to show for is absent rather than empty, so a +/// separator can be drawn between every pair of turns received. +struct ConversationTurn: Decodable, Sendable, Equatable, Identifiable { + /// Where the turn sits in the conversation, counting from zero. + /// + /// The position among *all* turns, so the numbering skips any the library + /// had nothing to show for. Two consecutive turns here can therefore be + /// numbered 4 and 7. + let index: Int + + /// What the turn has to show, oldest first. + let events: [ConversationEvent] + + var id: Int { index } + + private enum CodingKeys: String, CodingKey { + case index + case events + } + + init(index: Int, events: [ConversationEvent]) { + self.index = index + self.events = events + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + index = try container.decode(Int.self, forKey: .index) + events = try container.decode([SkippableEvent].self, forKey: .events) + .compactMap(\.event) + } +} + +/// An event that decodes to nothing when this build cannot draw it. +/// +/// A later library adding a presentation — a tool call, an attachment — would +/// otherwise fail the whole conversation on an app that predates it. Only an +/// unrecognized `type` is skipped; a known presentation missing its fields +/// still throws. +private struct SkippableEvent: Decodable { + let event: ConversationEvent? + + init(from decoder: any Decoder) throws { + do { + event = try ConversationEvent(from: decoder) + } catch is ConversationEvent.UnknownPresentation { + event = nil + } + } +} diff --git a/apps/macos/Sources/ConversationWindow.swift b/apps/macos/Sources/ConversationWindow.swift new file mode 100644 index 000000000..6c12965a2 --- /dev/null +++ b/apps/macos/Sources/ConversationWindow.swift @@ -0,0 +1,41 @@ +import SwiftUI + +/// One conversation, in a window of its own. +/// +/// Opened by double-clicking a conversation, and restored at launch from the +/// reference the system kept, which is why a reference carries its workspace +/// path: there may be no workspace window open to ask. +struct ConversationWindow: View { + /// The scene identifier `openWindow` addresses this group by. + static let sceneID = "conversation" + + let reference: ConversationRef? + + @State private var model = WorkspaceModel() + + var body: some View { + Group { + if let reference { + ConversationHistoryView(model: model, conversationID: reference.conversationID) + .navigationTitle(reference.displayTitle) + // Reachable from whichever Space is on screen, for a driven + // build. See ``DebugSpaces``. + .background(DebugSpaces.joinEverySpace()) + } else { + ContentUnavailableView( + "No Conversation", + systemImage: "bubble.left.and.text.bubble.right", + description: Text("This window has nothing to show.") + ) + } + } + .task(id: reference) { await load() } + } + + /// Open the reference's workspace, which this window does not share with the + /// one the conversation came from. + private func load() async { + guard let reference, !reference.workspacePath.isEmpty else { return } + await model.open(reference.workspacePath) + } +} diff --git a/apps/macos/Sources/DebugSpaces.swift b/apps/macos/Sources/DebugSpaces.swift new file mode 100644 index 000000000..97d42f120 --- /dev/null +++ b/apps/macos/Sources/DebugSpaces.swift @@ -0,0 +1,57 @@ +import AppKit +import SwiftUI + +/// Keeps a driven window reachable whichever Space is on screen. +/// +/// macOS remembers which Space an application's windows belong to, keyed by +/// bundle identifier. Each debug slot runs its own copy of the app under its own +/// identifier — which is what isolates window state and the recents list — so a +/// slot's copy can acquire a Space assignment of its own and go on reopening +/// there. The assignment lives in the window server, not in the slot's state +/// directory, so nothing the harness controls can clear it. +/// +/// A window on a Space that is not showing is not merely out of reach of a +/// synthesized click: it is absent from the accessibility tree entirely. Every +/// step a driver takes fails, and it fails as `identifier_not_found` — which +/// reads like a view that was never built rather than a window sitting one Space +/// away. +/// +/// `canJoinAllSpaces` makes the window present wherever the person looking at it +/// happens to be, so the tree finds it and its frame means what the screen shows. +/// Activating the app would also work and would steal focus on every launch, +/// which a harness that deliberately launches in the background must not do. +/// +/// Add it as a background of a window's content: +/// +/// ```swift +/// content.background(DebugSpaces.joinEverySpace()) +/// ``` +enum DebugSpaces { + /// A view that puts its window on every Space, for a driven build. + /// + /// Draws nothing, and does nothing at all unless the app was launched with a + /// debug state directory. A window that followed the Space in an app somebody + /// installed would be a window that will not stay where it was put. + static func joinEverySpace() -> some View { + Joiner() + } + + private struct Joiner: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + Probe() + } + + func updateNSView(_ view: NSView, context: Context) {} + } + + /// A view that does nothing but widen its window's Space membership. + private final class Probe: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + + guard DebugState.directory != nil, let window else { return } + + window.collectionBehavior.insert(.canJoinAllSpaces) + } + } +} diff --git a/apps/macos/Sources/DebugState.swift b/apps/macos/Sources/DebugState.swift new file mode 100644 index 000000000..162f07552 --- /dev/null +++ b/apps/macos/Sources/DebugState.swift @@ -0,0 +1,158 @@ +import AppKit +import Foundation +import SwiftUI + +/// The scratch directory a harness driving the app points it at. +/// +/// With `JP_DEBUG_STATE_DIR` set, the app keeps the state it would otherwise share +/// with the rest of the system inside that directory, and records its process id +/// there. Unset, nothing under it is touched and the app behaves as it ships. +/// +/// This exists because the alternatives do not work. The recent-workspace list is +/// keyed by bundle identifier and written on the app's behalf by a system daemon, +/// so it follows neither `HOME` nor anything else in the app's environment; and the +/// file holding it needs Full Disk Access to read, so a harness cannot inspect or +/// restore it either. +/// +/// Window state saved by `@SceneStorage` is **not** covered by this directory. It +/// is keyed by bundle identifier, so isolating it is the launching harness's job +/// rather than something this variable can reach. +enum DebugState { + /// The environment variable naming the directory. + static let variable = "JP_DEBUG_STATE_DIR" + + /// The environment variable naming a pasteboard to copy to. + static let pasteboardVariable = "JP_DEBUG_PASTEBOARD" + + /// The pasteboard the app copies to. + /// + /// The system one, unless a debug build was told otherwise. There is a + /// single system pasteboard and it holds whatever the person at the + /// keyboard last copied, so a driven run that copied into it would destroy + /// their clipboard. Saving and restoring around the run is not a way out: + /// a pasteboard item can be a promise its owner fulfils lazily, so what + /// goes back is a degraded copy of what they had. + /// + /// A named pasteboard is a real one that simply nobody is looking at, so a + /// test can read back exactly what the app wrote. + /// + /// Compiled out of a release build. An app that could be told at launch to + /// copy somewhere nothing pastes from is a bug report waiting to happen, + /// and that risk is not worth carrying to ship a test seam. + static var pasteboard: NSPasteboard { + #if DEBUG + if let name = ProcessInfo.processInfo.environment[pasteboardVariable], + !name.isEmpty + { + return NSPasteboard(name: NSPasteboard.Name(name)) + } + #endif + + return .general + } + + /// The environment variable that turns the app's animations off. + static let animationVariable = "JP_DEBUG_DISABLE_ANIMATIONS" + + /// Whether the app should animate at all. + /// + /// A UI test driving the app waits for it to stop moving before each + /// action, so every animation is time added to every test that triggers + /// one. Turning them off is worth more than shortening them, and costs a + /// test nothing it was checking: what an animation looks like is a question + /// for a person, and `QA.md` keeps it. + /// + /// Compiled out of a release build, like ``pasteboard``, so an app someone + /// installs cannot be talked into feeling broken. + static var animationsDisabled: Bool { + #if DEBUG + guard let value = ProcessInfo.processInfo.environment[animationVariable] else { + return false + } + + return !value.isEmpty + #else + return false + #endif + } + + /// `animation` normally, and nothing when animations are off. + /// + /// Every animation in the app goes through this, so turning them off stays + /// one decision rather than one per call site. + static func animated(_ animation: Animation) -> Animation? { + animationsDisabled ? nil : animation + } + + /// The directory, or `nil` when the variable is unset or empty. + static var directory: URL? { + guard let value = ProcessInfo.processInfo.environment[variable], !value.isEmpty else { + return nil + } + + return URL(fileURLWithPath: value) + } + + /// The recents store the app runs with. + @MainActor + static func defaultStore() -> any RecentsStore { + guard let directory else { + return DocumentControllerRecents() + } + + return FileRecents(path: directory.appendingPathComponent("recents.json")) + } + + /// Record this process's id at `/pid`. + /// + /// A harness launching the app through `open(1)` gets no process id back, and + /// matching on the executable path cannot tell a driven instance from one the + /// developer left running. A pid the app reports itself is unambiguous. + static func recordProcessID() { + guard let directory else { + return + } + + let file = directory.appendingPathComponent("pid") + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + try "\(getpid())\n".write(to: file, atomically: true, encoding: .utf8) + } catch { + let path = file.path(percentEncoded: false) + FileHandle.standardError.write( + Data("debug state: could not write \(path): \(error)\n".utf8) + ) + } + + recordImageSlide() + } + + /// Record how far ASLR shifted this process's main image. + /// + /// A profiler resolves a sampled address by subtracting this from it, and the + /// alternative is recovering it from the kernel's image-load events — which + /// only exist in a trace that was already recording when dyld mapped the + /// image. A recorder that attached to an already-running app has none of + /// them, so without this every frame it samples stays a bare address. + /// + /// Index 0 is the main executable. + private static func recordImageSlide() { + guard let directory else { + return + } + + let file = directory.appendingPathComponent("slide") + let slide = _dyld_get_image_vmaddr_slide(0) + do { + try "\(slide)\n".write(to: file, atomically: true, encoding: .utf8) + } catch { + let path = file.path(percentEncoded: false) + FileHandle.standardError.write( + Data("debug state: could not write \(path): \(error)\n".utf8) + ) + } + } +} diff --git a/apps/macos/Sources/JPApp.swift b/apps/macos/Sources/JPApp.swift new file mode 100644 index 000000000..dbbdffd56 --- /dev/null +++ b/apps/macos/Sources/JPApp.swift @@ -0,0 +1,171 @@ +import SwiftUI + +/// A reader for JP conversations. +/// +/// A plain `WindowGroup`, deliberately: keying the group by workspace path made +/// each window's identity its workspace, which meant ⌘N on a workspace already on +/// screen brought that window forward instead of opening one, and ⌘T had nothing +/// to duplicate. Each window now decides which workspace it shows, and holds that +/// choice in its own scene storage. +@main +struct JPApp: App { + @State private var recents = RecentWorkspaces() + + init() { + // Earliest point the app can report which process it is, for a harness + // that launched it through `open(1)` and got no pid back. + DebugState.recordProcessID() + + // Also the earliest point it can time itself from, which is what makes + // "launch to first window" a number rather than an impression. + Trace.beginLaunch() + } + + /// What the front window offers the File menu. + /// + /// A menu command acts on the focused window, and only that window knows + /// which workspace it is showing. + /// + /// Whatever is published here must compare equal to itself between renders. + /// See ``WorkspaceActions`` for what happens when it does not. + @FocusedValue(\.workspaceActions) private var actions + + @Environment(\.openWindow) private var openWindow + + /// The scene identifier `openWindow` addresses workspace windows by. + private static let workspaceSceneID = "workspace" + + var body: some Scene { + WindowGroup(id: Self.workspaceSceneID) { + WorkspaceWindow() + .environment(recents) + } + // No title bar, so no strip of chrome above the transcript and no title + // text repeating what the sidebar already says. The window buttons stay, + // over the top-left of the sidebar, and the window still drags by that + // strip. + .windowStyle(.hiddenTitleBar) + .commands { workspaceCommands } + + // A conversation pulled out of a workspace window, into its own. + WindowGroup(id: ConversationWindow.sceneID, for: ConversationRef.self) { $reference in + ConversationWindow(reference: reference) + } + } + + @CommandsBuilder + private var workspaceCommands: some Commands { + // Show/Hide Sidebar, in the View menu where AppKit puts it. Ours rather + // than `SidebarCommands()`, which acts on a `NavigationSplitView`'s column + // visibility and the window holds its two panes itself. + // + // A hidden sidebar takes the conversation list and the filter box with it, + // and there is no button for it, so this item and its keystroke are the + // only way back. + CommandGroup(after: .sidebar) { + Button(actions?.isSidebarVisible == false ? "Show Sidebar" : "Hide Sidebar") { + actions?.toggleSidebar() + } + .keyboardShortcut("s", modifiers: [.control, .command]) + .disabled(actions == nil) + } + + // Replaces "New", which a reader has no use for, but keeps "New Window": + // macOS hangs window tabbing off it, and without it there is nothing for + // ⌘T to duplicate. + CommandGroup(replacing: .newItem) { + Button("New Window") { openWindow(id: Self.workspaceSceneID) } + .keyboardShortcut("n", modifiers: .command) + + Divider() + + Button("Open Workspace…") { actions?.choose() } + .keyboardShortcut("o", modifiers: .command) + .disabled(actions == nil) + + Menu("Open Recent") { + ForEach(recents.urls, id: \.self) { url in + Button(url.lastPathComponent) { actions?.open(url) } + } + + if !recents.urls.isEmpty { + Divider() + Button("Clear Menu") { recents.clear() } + } + } + .disabled(recents.urls.isEmpty || actions == nil) + } + + // Copying a conversation's URI is the only thing the reader does to a + // conversation besides opening it, and the list's context menu is a + // pointing device away. In the Edit menu it also has a keystroke, and it + // is reachable by anything driving the app through the menu bar. + CommandGroup(after: .pasteboard) { + Button("Copy Link") { actions?.copyLinks() } + .keyboardShortcut("c", modifiers: [.command, .shift]) + .disabled(actions?.hasSelection != true) + } + } +} + +/// What the focused workspace window lets the File menu do to it. +/// +/// Equatable by window, not by content. A focused value is republished every time +/// the view publishing it renders, and the App observing it is invalidated +/// whenever the value differs. Closures never compare equal, so a value carrying +/// them and nothing else differs every single time: the window renders, the App is +/// invalidated, the scene is re-evaluated, the window renders again. +/// +/// That loop does not merely rebuild the menu bar — which discards the items +/// AppKit injects into View and Window, since `SwiftUI` reconstructs those menus +/// from its own commands and knows nothing of them. It re-renders the entire scene +/// continuously, and the whole app is sluggish for it: lists stutter as they +/// scroll, and the sidebar snaps rather than animating. +/// +/// Comparing the window's identity instead makes a republished value from the same +/// window look unchanged, which ends the loop. +struct WorkspaceActions: Equatable { + /// Identifies the window these act on, stable for that window's lifetime. + let windowID: UUID + + /// Whether the window has a conversation selected. + /// + /// Part of the equality along with the window, so a menu item conditioned on + /// it is re-evaluated when the selection appears or goes away, and at no + /// other time. + let hasSelection: Bool + + /// Whether the window's sidebar is showing. + /// + /// Part of the equality too, because the View menu's item is titled from it. + let isSidebarVisible: Bool + + /// Put the directory chooser on screen. + let choose: () -> Void + + /// Show a workspace in this window. + let open: (URL) -> Void + + /// Put the selected conversation's URI on the pasteboard. + let copyLinks: () -> Void + + /// Show the sidebar if it is hidden, hide it if it is showing. + let toggleSidebar: () -> Void + + static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.windowID == rhs.windowID + && lhs.hasSelection == rhs.hasSelection + && lhs.isSidebarVisible == rhs.isSidebarVisible + } +} + +struct WorkspaceActionsKey: FocusedValueKey { + typealias Value = WorkspaceActions +} + +extension FocusedValues { + var workspaceActions: WorkspaceActions? { + get { self[WorkspaceActionsKey.self] } + set { self[WorkspaceActionsKey.self] = newValue } + } +} diff --git a/apps/macos/Sources/ListSelectionHighlight.swift b/apps/macos/Sources/ListSelectionHighlight.swift new file mode 100644 index 000000000..27bc2cbe6 --- /dev/null +++ b/apps/macos/Sources/ListSelectionHighlight.swift @@ -0,0 +1,79 @@ +import AppKit +import SwiftUI + +/// Stops the table view under a SwiftUI `List` from drawing its own selection. +/// +/// Only the drawing is suppressed. The selection is still the list's, so click +/// selection, the arrow keys and `contextMenu(forSelectionType:)` all keep +/// working, and the row draws the selection the design calls for. +/// +/// This reaches for AppKit because SwiftUI offers no way to say it. A `List` on +/// macOS is an `NSTableView`, and a selected row is filled with the system accent +/// colour by the row view itself, underneath whatever the row draws. Neither +/// `listRowBackground` nor an opaque fill in the row's own content hides it. +/// +/// Put it in a *row*, not behind the list: +/// +/// ```swift +/// List(...) { item in +/// ItemRow(item) +/// .background(ListSelectionHighlight.removed) +/// } +/// ``` +/// +/// A row's backing view is a descendant of the table view, so it can walk up to +/// the table in two hops. A view placed behind the whole list cannot: it is built +/// before the table exists, and finds nothing to configure. +enum ListSelectionHighlight { + /// A view that turns the highlight off for the table holding it. + /// + /// Draws nothing, and fills whatever it is given rather than being sized to + /// nothing: SwiftUI builds no backing view for a subview with no area, and one + /// that is never built never runs. + static var removed: some View { + Remover() + } + + private struct Remover: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + Probe() + } + + /// Applied again on every update, which is what makes this hold: rows are + /// realized and recycled as the list scrolls, and a table view SwiftUI + /// rebuilt is back to drawing its own selection until the next row asks it + /// not to. + func updateNSView(_ view: NSView, context: Context) { + (view as? Probe)?.silenceSelection() + } + } + + /// A view that does nothing but reach the table view it sits inside. + private final class Probe: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + silenceSelection() + } + + /// Turn off the highlight on the table view above this one. + /// + /// Walks the ancestors and tests each, rather than searching their + /// subtrees: from inside a row the table is two hops up, and searching a + /// table's subtree means walking every row it has realized. + /// + /// Finds nothing when called before the row is in the hierarchy, which the + /// first call after `makeNSView` always is. The call from + /// `viewDidMoveToWindow` is the one that lands. + func silenceSelection() { + var ancestor = superview + + while let current = ancestor { + if let table = current as? NSTableView { + table.selectionHighlightStyle = .none + return + } + ancestor = current.superview + } + } + } +} diff --git a/apps/macos/Sources/Markdown.swift b/apps/macos/Sources/Markdown.swift new file mode 100644 index 000000000..317b8eff0 --- /dev/null +++ b/apps/macos/Sources/Markdown.swift @@ -0,0 +1,460 @@ +import AppKit +import Foundation + +/// Markdown, turned into text a TextKit view can draw. +/// +/// Foundation's own parser does the reading: `AttributedString(markdown:)` with +/// full syntax records block structure in the `presentationIntent` attribute and +/// inline styling in `inlinePresentationIntent`. It records and does not render, +/// so the work here is the translation — intents into fonts, colours, paragraph +/// styles and list markers. +/// +/// Foundation leaves out the separators between blocks: two paragraphs come back +/// as adjacent runs with nothing between them. The newlines are put back here, +/// which is also what makes block spacing this file's to decide. +enum Markdown { + /// `source` as attributed text, with its block structure drawn. + /// + /// Text that cannot be parsed is returned as itself in the body style, so a + /// malformed message still shows its content. + static func attributed(_ source: String, style: MarkdownStyle) -> NSAttributedString { + let parsed = parse(source) + let output = NSMutableAttributedString() + + // Which list items have had their bullet drawn. A list item holding two + // paragraphs is two blocks, and only the first of them is marked. + var marked: Set = [] + + // The table row being gathered. Every cell is a block of its own, and a + // row is one line of tab-separated cells, so the cells are held until the + // row they belong to ends. + var row: TableRow? + + for block in blocks(of: parsed) { + let components = block.intent?.components ?? [] + + if let cell = tableCell(in: components) { + if row?.identity != cell.row { + flush(&row, into: output, style: style) + row = TableRow( + identity: cell.row, columns: cell.columns, isHeader: cell.isHeader) + } + + row?.cells.append( + content( + of: block, in: parsed, + font: cell.isHeader ? style.body.with(.bold) : style.body, + colour: style.text, style: style, inCodeBlock: false + ) + ) + continue + } + + flush(&row, into: output, style: style) + output.append(rendered(block, of: parsed, style: style, marked: &marked)) + } + + flush(&row, into: output, style: style) + + // Every block ends with the newline separating it from the next, so the + // last one leaves a trailing empty line. + if output.length > 0 { + output.deleteCharacters(in: NSRange(location: output.length - 1, length: 1)) + } + + return output + } + + /// One run of characters sharing a block intent. + private struct Block { + /// What the parser said this block is, absent for text it left unmarked. + let intent: PresentationIntent? + + /// Where the block sits in the parsed string. + let range: Range + } + + /// One row of a table, gathered cell by cell. + /// + /// Foundation reports a table as one block per cell, each carrying the row and + /// the table above it. A row is drawn as a single paragraph of tab-separated + /// cells, so the cells are collected until the row changes. + private struct TableRow { + /// The row's own identity, which is what says a cell belongs to it. + let identity: Int + + /// The table's columns, in order, carrying the alignment each was + /// declared with. + let columns: [PresentationIntent.TableColumn] + + /// Whether this is the header row, which is drawn in bold. + let isHeader: Bool + + var cells: [NSAttributedString] = [] + } + + /// What a list item's marker is, and whether it has been drawn yet. + private struct ListItem { + let ordinal: Int + let identity: Int + let ordered: Bool + } + + private static func parse(_ source: String) -> AttributedString { + let parsed = try? AttributedString( + markdown: source, + options: .init( + allowsExtendedAttributes: false, + interpretedSyntax: .full, + failurePolicy: .returnPartiallyParsedIfPossible + ) + ) + + return parsed ?? AttributedString(source) + } + + /// The parsed string cut into blocks. + /// + /// Adjacent runs belong to the same block when they carry the same intent: + /// every block the parser produces has an identity of its own, so two + /// neighbouring list items compare unequal even though both are paragraphs + /// in an unordered list. + private static func blocks(of parsed: AttributedString) -> [Block] { + var blocks: [Block] = [] + + for run in parsed.runs { + if let last = blocks.last, last.intent == run.presentationIntent { + blocks[blocks.count - 1] = Block( + intent: last.intent, + range: last.range.lowerBound.. + ) -> NSAttributedString { + let components = block.intent?.components ?? [] + let leaf = components.first?.kind + let item = listItem(in: components) + let isCodeBlock = if case .codeBlock = leaf { true } else { false } + + let font = blockFont(leaf, style: style) + let colour = blockColour(leaf, quoted: quoteDepth(in: components), style: style) + let paragraph = paragraphStyle( + leaf: leaf, + indent: style.indent + * CGFloat(listDepth(in: components) + quoteDepth(in: components)), + marked: item != nil, + style: style + ) + + let content = NSMutableAttributedString() + + if let item, marked.insert(item.identity).inserted { + content.append( + NSAttributedString( + string: "\(item.ordered ? "\(item.ordinal)." : "•")\t", + attributes: [.font: font, .foregroundColor: colour] + ) + ) + } + + content.append( + self.content( + of: block, in: parsed, font: font, colour: colour, style: style, + inCodeBlock: isCodeBlock) + ) + + // A fenced block's content keeps the newline before its closing fence, + // which would draw an empty last line inside the block. + if isCodeBlock { + while content.string.hasSuffix("\n") { + content.deleteCharacters(in: NSRange(location: content.length - 1, length: 1)) + } + } + + content.append(NSAttributedString(string: "\n", attributes: [.font: font])) + content.addAttribute( + .paragraphStyle, value: paragraph, + range: NSRange(location: 0, length: content.length)) + + if isCodeBlock { + content.addAttribute( + .backgroundColor, value: style.codeBackground, + range: NSRange(location: 0, length: content.length)) + } + + return content + } + + /// Draw the gathered row, if there is one, and forget it. + /// + /// Cells are separated by tabs and the paragraph carries one stop per column + /// boundary, so a cell begins where its column does. The stop takes the + /// alignment the column was declared with, which is the one piece of table + /// styling the source actually states — `---:` in the separator row right- + /// aligns a column of numbers, and Foundation reports it. + private static func flush( + _ row: inout TableRow?, into output: NSMutableAttributedString, style: MarkdownStyle + ) { + guard let gathered = row, !gathered.cells.isEmpty else { + row = nil + return + } + + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = style.lineSpacing + // A table reads as one block, so the space goes after the last row rather + // than between every pair of them. The rows of one table are consecutive, + // and whatever follows opens with its own spacing. + paragraph.paragraphSpacing = 0 + paragraph.tabStops = gathered.columns.indices.dropFirst().map { column in + NSTextTab( + textAlignment: alignment(of: gathered.columns[column]), + location: CGFloat(column) * style.tableColumnWidth + ) + } + + let line = NSMutableAttributedString() + for (column, cell) in gathered.cells.enumerated() { + if column > 0 { + line.append(NSAttributedString(string: "\t")) + } + line.append(cell) + } + + line.append(NSAttributedString(string: "\n", attributes: [.font: style.body])) + line.addAttribute( + .paragraphStyle, value: paragraph, range: NSRange(location: 0, length: line.length)) + + output.append(line) + row = nil + } + + /// How a column's cells sit against their tab stop. + private static func alignment( + of column: PresentationIntent.TableColumn + ) + -> NSTextAlignment + { + switch column.alignment { + case .left: .left + case .center: .center + case .right: .right + @unknown default: .left + } + } + + /// The row and table a cell belongs to, or `nil` when the block is not a cell. + /// + /// Components run innermost first, so a cell's are the cell, then its row, + /// then the table. + private static func tableCell( + in components: [PresentationIntent.IntentType] + ) + -> (row: Int, columns: [PresentationIntent.TableColumn], isHeader: Bool)? + { + guard let leaf = components.first?.kind else { return nil } + guard case .tableCell = leaf else { return nil } + guard components.count >= 3, case .table(let columns) = components[2].kind else { + return nil + } + + let isHeader: Bool + switch components[1].kind { + case .tableHeaderRow: isHeader = true + case .tableRow: isHeader = false + default: return nil + } + + return (components[1].identity, columns, isHeader) + } + + /// A block's runs, styled inline over a base font and colour. + private static func content( + of block: Block, + in parsed: AttributedString, + font: NSFont, + colour: NSColor, + style: MarkdownStyle, + inCodeBlock: Bool + ) -> NSAttributedString { + let content = NSMutableAttributedString() + + for run in parsed[block.range].runs { + content.append( + inline( + run, text: String(parsed[run.range].characters), font: font, + colour: colour, style: style, inCodeBlock: inCodeBlock) + ) + } + + return content + } + + /// One run of a block, with its inline styling applied over the block's. + private static func inline( + _ run: AttributedString.Runs.Run, + text: String, + font: NSFont, + colour: NSColor, + style: MarkdownStyle, + inCodeBlock: Bool + ) -> NSAttributedString { + let intent = run.inlinePresentationIntent ?? [] + var font = font + + if intent.contains(.stronglyEmphasized) { + font = font.with(.bold) + } + if intent.contains(.emphasized) { + font = font.with(.italic) + } + + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: colour, + ] + + // Only a span inside prose: the whole of a fenced block is already + // monospaced and already sitting on the code background. + if intent.contains(.code), !inCodeBlock { + attributes[.font] = style.monospaced + attributes[.foregroundColor] = style.codeText + attributes[.backgroundColor] = style.codeBackground + } + + if intent.contains(.strikethrough) { + attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + } + + if let url = run.link { + attributes[.link] = url + attributes[.foregroundColor] = style.link + attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + } + + // A hard break carries the text it was written with — two spaces, or a + // backslash — and means a new line inside the same paragraph. + let text = intent.contains(.lineBreak) ? "\n" : text + + return NSAttributedString(string: text, attributes: attributes) + } + + /// The innermost list item enclosing a block, if it is in a list at all. + /// + /// Components run innermost first, so the list an item belongs to is the + /// component after it, and that is what says whether the marker is a bullet + /// or a number. + private static func listItem(in components: [PresentationIntent.IntentType]) -> ListItem? { + guard + let index = components.firstIndex(where: { + if case .listItem = $0.kind { true } else { false } + }), + case .listItem(let ordinal) = components[index].kind + else { + return nil + } + + let enclosing = components.dropFirst(index + 1).first?.kind + let ordered = if case .orderedList = enclosing { true } else { false } + + return ListItem( + ordinal: ordinal, identity: components[index].identity, ordered: ordered) + } + + private static func listDepth(in components: [PresentationIntent.IntentType]) -> Int { + components.count { + switch $0.kind { + case .orderedList, .unorderedList: true + default: false + } + } + } + + private static func quoteDepth(in components: [PresentationIntent.IntentType]) -> Int { + components.count { + if case .blockQuote = $0.kind { true } else { false } + } + } + + private static func blockFont( + _ leaf: PresentationIntent.Kind?, style: MarkdownStyle + ) -> NSFont { + switch leaf { + case .header(let level): + NSFont.systemFont(ofSize: style.headingSize(level), weight: .semibold) + case .codeBlock: + style.monospaced + default: + style.body + } + } + + private static func blockColour( + _ leaf: PresentationIntent.Kind?, quoted: Int, style: MarkdownStyle + ) -> NSColor { + switch leaf { + case .codeBlock: style.codeText + case .thematicBreak: style.secondary + default: quoted > 0 ? style.secondary : style.text + } + } + + private static func paragraphStyle( + leaf: PresentationIntent.Kind?, + indent: CGFloat, + marked: Bool, + style: MarkdownStyle + ) -> NSParagraphStyle { + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = style.lineSpacing + paragraph.paragraphSpacing = style.blockSpacing + paragraph.firstLineHeadIndent = indent + paragraph.headIndent = indent + + // A heading opens a section, so it wants air above it as well as below. + if case .header = leaf { + paragraph.paragraphSpacingBefore = style.blockSpacing + } + + if case .thematicBreak = leaf { + paragraph.alignment = .center + } + + // The marker hangs in the indent its own level added, and a tab puts the + // text back at the indent — so a wrapped line lines up under the first + // rather than under the bullet. + if marked { + paragraph.firstLineHeadIndent = max(indent - style.indent, 0) + paragraph.tabStops = [NSTextTab(textAlignment: .left, location: max(indent, 1))] + paragraph.defaultTabInterval = style.indent + } + + return paragraph + } +} + +extension NSFont { + /// This font with `traits` added to whatever it already has. + /// + /// Through the descriptor rather than `NSFontManager`, which is main-actor + /// bound and would isolate the whole renderer to the main actor for the sake + /// of making one word bold. + func with(_ traits: NSFontDescriptor.SymbolicTraits) -> NSFont { + let descriptor = fontDescriptor.withSymbolicTraits( + fontDescriptor.symbolicTraits.union(traits)) + + return NSFont(descriptor: descriptor, size: pointSize) ?? self + } +} diff --git a/apps/macos/Sources/MarkdownStyle.swift b/apps/macos/Sources/MarkdownStyle.swift new file mode 100644 index 000000000..0629e941b --- /dev/null +++ b/apps/macos/Sources/MarkdownStyle.swift @@ -0,0 +1,104 @@ +import AppKit + +/// The fonts, colours and metrics block markdown is drawn with. +/// +/// Passed in rather than read from ``Theme`` inside the renderer, so the +/// translation from markdown to attributes can be checked against fixed numbers +/// without a running app deciding what "body text" resolves to. +struct MarkdownStyle { + /// Prose, and the size every other size is derived from. + var body: NSFont + + /// Code spans and code blocks. + var monospaced: NSFont + + /// What prose is drawn in. + var text: NSColor + + /// What a block quote and a thematic break are drawn in. + var secondary: NSColor + + /// Behind a code span or a code block. + var codeBackground: NSColor + + /// A code span's or code block's text. + var codeText: NSColor + + /// A link's text, which is also what underlines it. + var link: NSColor + + /// How far one level of list or quote nesting indents. + var indent: CGFloat + + /// How wide one column of a table is. + /// + /// Fixed rather than measured. Measuring would mean laying every cell out to + /// find the widest, at a width the container has not settled on yet, and + /// re-doing it on every resize — for a reader, not an editor. A column wide + /// enough for a short phrase is what a plain-text table gives and is legible + /// at the sizes JP transcripts use. + var tableColumnWidth: CGFloat + + /// The gap left below a block, before the next one. + var blockSpacing: CGFloat + + /// How much taller than its font a line of prose is drawn. + var lineSpacing: CGFloat + + /// The gap above a message that follows another in the same turn. + var eventSpacing: CGFloat + + /// The gap above the first message of a turn. + /// + /// Wider than ``eventSpacing``, because it is the only thing separating one + /// turn from the last. + var turnSpacing: CGFloat + + /// The app's palette, at the reading size. + /// + /// `appearance` decides which half of each ``ThemeColor`` is taken, because a + /// colour baked into an attributed string is resolved once when the string is + /// built rather than each time it is drawn. + @MainActor + static func reading(in appearance: NSAppearance) -> MarkdownStyle { + let size = NSFont.systemFontSize + 1 + + return MarkdownStyle( + body: .systemFont(ofSize: size), + monospaced: .monospacedSystemFont(ofSize: size - 1, weight: .regular), + text: resolved(Theme.bodyText, in: appearance), + secondary: resolved(Theme.secondaryText, in: appearance), + codeBackground: resolved(Theme.inlineCodeBackground, in: appearance), + codeText: resolved(Theme.inlineCodeText, in: appearance), + link: resolved(Theme.accent, in: appearance), + indent: 22, + tableColumnWidth: 150, + blockSpacing: 10, + lineSpacing: 3, + eventSpacing: 18, + turnSpacing: 40 + ) + } + + /// How large a heading of `level` is drawn, relative to ``body``. + /// + /// Levels past the third are the body size in bold, which is what a document + /// nested that deep wants: another distinct size would be a difference nobody + /// can see. + func headingSize(_ level: Int) -> CGFloat { + let scale: CGFloat = + switch level { + case 1: 1.6 + case 2: 1.35 + case 3: 1.15 + default: 1 + } + + return (body.pointSize * scale).rounded() + } + + /// One palette colour, fixed to the half `appearance` shows. + private static func resolved(_ colour: ThemeColor, in appearance: NSAppearance) -> NSColor { + ThemeColor.srgb(colour.value(under: appearance)) + } +} diff --git a/apps/macos/Sources/RecentWorkspaces.swift b/apps/macos/Sources/RecentWorkspaces.swift new file mode 100644 index 000000000..e4279c0b7 --- /dev/null +++ b/apps/macos/Sources/RecentWorkspaces.swift @@ -0,0 +1,82 @@ +import Foundation +import Observation + +/// The workspaces opened before, most recent first. +/// +/// Reads and writes the list through a ``RecentsStore``, and owns the two rules +/// that apply whichever store is in use: paths are canonicalized on the way in, +/// and directories that have gone away are dropped on the way out. +/// +/// The `File ▸ Open Recent` menu is built from ``urls`` explicitly. AppKit manages +/// that menu on its own only for a document-based app, which this is not. +@MainActor +@Observable +final class RecentWorkspaces { + private(set) var urls: [URL] = [] + + private let store: any RecentsStore + + init(store: any RecentsStore) { + self.store = store + urls = Self.pruned(store.urls()) + } + + /// A list backed by whichever store the app's environment selects. + convenience init() { + self.init(store: DebugState.defaultStore()) + } + + /// Record a workspace as opened, moving it to the front. + /// + /// The URL is canonicalized first. `NSDocumentController` resolves symlinks + /// when it stores one, and macOS symlinks `/var` and `/tmp`, so noting a URL + /// as given would put a path in the menu that never matches the one a window + /// was opened with — and windows are keyed by path, so the same workspace + /// would open twice. + func note(_ url: URL) { + store.note(url.canonicalized) + urls = Self.pruned(store.urls()) + } + + /// Forget every recorded workspace. + func clear() { + store.clear() + urls = Self.pruned(store.urls()) + } + + /// The recorded workspaces that still exist on disk, canonicalized. + /// + /// A directory can be deleted or unmounted between launches, and offering to + /// open one that is gone only produces an error the user cannot act on. + private static func pruned(_ urls: [URL]) -> [URL] { + urls.map(\.canonicalized).filter { url in + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists( + atPath: url.path(percentEncoded: false), + isDirectory: &isDirectory + ) + return exists && isDirectory.boolValue + } + } +} + +extension URL { + /// The URL with symlinks resolved and any trailing slash dropped, so two + /// spellings of one directory compare equal. + /// + /// `URL(fileURLWithPath:)` checks the filesystem and marks an existing + /// directory as one, which puts a trailing slash into every path read back out. + /// Windows are keyed by that path, so a list holding `/a/b/` while a window is + /// keyed by `/a/b` lets one workspace open twice. + /// + /// `isDirectory: false` is what keeps the slash off, and is not a claim about + /// what is at the path: it declares the spelling rather than letting the + /// filesystem pick one, which is the whole point of a canonical form. + var canonicalized: URL { + let path = resolvingSymlinksInPath().path(percentEncoded: false) + let trimmed = + path.count > 1 && path.hasSuffix("/") ? String(path.dropLast()) : path + + return URL(fileURLWithPath: trimmed, isDirectory: false) + } +} diff --git a/apps/macos/Sources/RecentsStore.swift b/apps/macos/Sources/RecentsStore.swift new file mode 100644 index 000000000..b335a4930 --- /dev/null +++ b/apps/macos/Sources/RecentsStore.swift @@ -0,0 +1,126 @@ +import AppKit +import Foundation + +/// Where the recent-workspace list is kept. +/// +/// Storing the list is all this covers. Canonicalizing paths and dropping +/// directories that have gone away are policy ``RecentWorkspaces`` applies above +/// it, so every implementation agrees on them. +@MainActor +protocol RecentsStore { + /// The recorded workspaces, most recent first. + func urls() -> [URL] + + /// Record a workspace as opened, moving it to the front. + func note(_ url: URL) + + /// Forget every recorded workspace. + func clear() +} + +/// The recent-workspace list as AppKit keeps it. +/// +/// `NSDocumentController`'s list persists across launches and is shared with the +/// system, which is what puts the app's workspaces in its Dock menu. It is keyed +/// by bundle identifier, so every process running this app reads and writes one +/// list — the test bundle included, since the tests run hosted by the app. +struct DocumentControllerRecents: RecentsStore { + func urls() -> [URL] { + NSDocumentController.shared.recentDocumentURLs + } + + func note(_ url: URL) { + NSDocumentController.shared.noteNewRecentDocumentURL(url) + } + + func clear() { + NSDocumentController.shared.clearRecentDocuments(nil) + } +} + +/// The recent-workspace list kept as JSON at a path of the caller's choosing. +/// +/// Paths are stored as an array of strings, most recent first, so a harness can +/// read the list it drove the app into directly rather than through the +/// accessibility tree: +/// +/// ```json +/// ["/Users/jean/Projects/jp", "/tmp/probe-ws"] +/// ``` +/// +/// Nothing is cached: every call reads the file. The list holds ten paths and a +/// harness may rewrite it between launches, so there is nothing here worth the +/// risk of serving a stale answer. +struct FileRecents: RecentsStore { + /// The JSON file backing the list, created on the first ``note(_:)``. + let path: URL + + /// How many paths the list keeps, matching what `NSDocumentController` stores + /// by default. + static let capacity = 10 + + /// The recorded workspaces, most recent first. + /// + /// A file that is not there yet is an empty list rather than an error: that is + /// the state before anything has been opened. A file that is there but + /// unreadable is reported and also read as empty, because refusing to produce + /// a list would cost the window its workspace. + /// + /// `isDirectory: false` keeps the round-trip verbatim. The plain + /// `URL(fileURLWithPath:)` consults the filesystem and appends a slash to a + /// path that names a directory, so a path would read back spelled differently + /// from how it was written and ``note(_:)`` would stop recognizing it. + func urls() -> [URL] { + guard let data = try? Data(contentsOf: path) else { + return [] + } + + do { + let paths = try JSONDecoder().decode([String].self, from: data) + return paths.map { URL(fileURLWithPath: $0, isDirectory: false) } + } catch { + report("could not read \(path.path(percentEncoded: false)): \(error)") + return [] + } + } + + func note(_ url: URL) { + let noted = url.path(percentEncoded: false) + var paths = urls().map { $0.path(percentEncoded: false) } + + // Dropping any earlier spelling of the same path before inserting is what + // makes this a move-to-front rather than a second entry. + paths.removeAll { $0 == noted } + paths.insert(noted, at: 0) + + write(Array(paths.prefix(Self.capacity))) + } + + func clear() { + write([]) + } + + private func write(_ paths: [String]) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted] + + do { + try FileManager.default.createDirectory( + at: path.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try encoder.encode(paths).write(to: path, options: .atomic) + } catch { + report("could not write \(path.path(percentEncoded: false)): \(error)") + } + } + + /// Note a failure on stderr. + /// + /// The list is a convenience, and a launch that cannot persist it should still + /// open a window. Reporting rather than throwing keeps that true, and stderr + /// is where a harness driving the app is already reading. + private func report(_ message: String) { + FileHandle.standardError.write(Data("recents: \(message)\n".utf8)) + } +} diff --git a/apps/macos/Sources/SearchField.swift b/apps/macos/Sources/SearchField.swift new file mode 100644 index 000000000..cc4bc9201 --- /dev/null +++ b/apps/macos/Sources/SearchField.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// The box that narrows the conversation list. +/// +/// Built rather than styled, because none of the stock text field styles gives a +/// glyph inside the field, and the bordered ones draw a focus ring the design +/// does not have. +/// +/// Carries no outer padding, so a caller can place it against the window buttons +/// and give it the height it needs to line up with them. +struct SearchField: View { + /// What has been typed. + @Binding var text: String + + /// The corner radius of the field and of its border, which have to match or + /// the stroke cuts across the fill. + private static let radius: CGFloat = 6 + + var body: some View { + HStack(spacing: 5) { + // Hidden from the accessibility tree: it says nothing the field's own + // label does not, and SwiftUI otherwise publishes an SF Symbol as an + // element identified by its symbol name — a name nothing here chose, + // sitting in the tree beside the ones that were. + Image(systemName: "magnifyingglass") + .foregroundStyle(Theme.secondaryText.color) + .accessibilityHidden(true) + + // The accessibility modifiers sit directly on the field, ahead of + // the layout ones, so they cannot land on a wrapper `padding` + // introduces. + // + // A collapsed sidebar takes the field out of the accessibility tree + // entirely, along with the list. A driver that cannot find either + // should check the sidebar is showing before concluding an + // identifier is missing. + // An empty title, with the placeholder drawn below instead: a + // `TextField`'s own placeholder takes the system's grey and no + // modifier reaches it, which leaves it several shades lighter than + // every other piece of secondary text in the sidebar. + TextField("", text: $text) + .accessibilityLabel("Filter conversations") + .accessibilityIdentifier(AccessibilityID.Sidebar.filter) + .textFieldStyle(.plain) + .foregroundStyle(Theme.bodyText.color) + .background(alignment: .leading) { + if text.isEmpty { + // Never a click target, or it would swallow the click that + // is meant to put the caret in the field. + Text(verbatim: "Filter") + .foregroundStyle(Theme.secondaryText.color) + .allowsHitTesting(false) + // The field already carries this as its label, so + // publishing it again would put two elements in the + // tree saying the same thing. + .accessibilityHidden(true) + } + } + + // Always there, whether or not there is anything to clear. A control + // that comes and goes with what has been typed moves the text's right + // edge as it appears, and the field is the one part of the sidebar + // that should not shift while somebody is typing into it. + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Theme.secondaryText.color) + } + .accessibilityLabel("Clear the filter") + .accessibilityIdentifier(AccessibilityID.Sidebar.filterClear) + .buttonStyle(.plain) + } + .font(.system(size: 13)) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: Self.radius) + .fill(Theme.searchFieldBackground.color) + // The field and the sidebar are the same colour in both + // appearances, so the border is the only thing that says where + // the field is. + .overlay( + RoundedRectangle(cornerRadius: Self.radius) + .strokeBorder(Theme.paneDivider.color, lineWidth: 1) + ) + ) + } +} diff --git a/apps/macos/Sources/Theme.swift b/apps/macos/Sources/Theme.swift new file mode 100644 index 000000000..905dfcd5e --- /dev/null +++ b/apps/macos/Sources/Theme.swift @@ -0,0 +1,112 @@ +import AppKit +import SwiftUI + +/// One colour of the palette, in both appearances. +/// +/// Held as sRGB numbers rather than as `Color`s, so a colour is defined in one +/// place for both appearances and the palette can be read and compared without +/// a running app. +struct ThemeColor: Equatable, Sendable { + /// The value used in light appearance, as `0xRRGGBB`. + let light: UInt32 + + /// The value used in dark appearance, as `0xRRGGBB`. + let dark: UInt32 + + /// The SwiftUI colour to draw with. + /// + /// Resolves per appearance as it draws rather than being fixed when it is + /// built: a window moved between appearances redraws from the same `Color` + /// value and has to pick up the other half. + var color: Color { + Color(nsColor: nsColor) + } + + /// The AppKit colour behind ``color``. + var nsColor: NSColor { + let (light, dark) = (self.light, self.dark) + + return NSColor(name: nil) { appearance in + Self.srgb(appearance.isDark ? dark : light) + } + } + + /// The value this shows under `appearance`. + func value(under appearance: NSAppearance) -> UInt32 { + appearance.isDark ? dark : light + } + + /// An opaque sRGB colour from `0xRRGGBB`. + static func srgb(_ hex: UInt32) -> NSColor { + NSColor( + srgbRed: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255, + alpha: 1 + ) + } +} + +extension NSAppearance { + /// Whether this is one of the dark appearances. + /// + /// Matched rather than compared by name, because the accessibility variants + /// (`accessibilityHighContrastDarkAqua` and friends) are dark too and have + /// names of their own. + var isDark: Bool { + bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + } +} + +/// The app's colours, in one place. +/// +/// Every surface and every piece of text picks its colour from here rather than +/// from a system semantic colour, because the app's appearance is a design +/// decision that has to hold across both appearances and both windows. +enum Theme { + /// Behind the conversation list. + static let sidebarBackground = ThemeColor(light: 0xFF_FFFF, dark: 0x1D_1E20) + + /// Behind the selected row of the conversation list. + static let selectedRowBackground = ThemeColor(light: 0xF4_F5F7, dark: 0x2E_2E30) + + /// The line between the sidebar and the transcript, and the search field's + /// border. + static let paneDivider = ThemeColor(light: 0xD9_D9D9, dark: 0x2C_2D2E) + + /// The line between two rows of the conversation list. + /// + /// Lighter than ``paneDivider``, because there is one of those and dozens of + /// these: at the pane divider's weight the list reads as a grid. + static let rowSeparator = ThemeColor(light: 0xE4_E5E6, dark: 0x2C_2D2E) + + /// Behind the search field. + static let searchFieldBackground = ThemeColor(light: 0xFF_FFFF, dark: 0x1D_1E20) + + /// Behind the transcript. + static let editorBackground = ThemeColor(light: 0xFF_FFFF, dark: 0x1D_1E20) + + /// Prose, and anything else a person is meant to read. + static let bodyText = ThemeColor(light: 0x44_4444, dark: 0xCC_DBE5) + + /// Dates, counts, speaker names — text that labels rather than says. + static let secondaryText = ThemeColor(light: 0x88_8888, dark: 0xA2_A3A4) + + /// The one colour that draws the eye: the pin glyph, and controls that tint. + /// + /// Red in light appearance and blue in dark, which is not a mistake — it is + /// what the design calls for. + static let accent = ThemeColor(light: 0xDD_4D4F, dark: 0x45_A2E5) + + /// Behind an inline code span. + static let inlineCodeBackground = ThemeColor(light: 0xF4_F5F7, dark: 0x2E_2E30) + + /// An inline code span's text. + static let inlineCodeText = ThemeColor(light: 0x44_4444, dark: 0xDF_E0E0) + + /// Behind a tag pill. + static let tagBackground = ThemeColor(light: 0xE4_E5E6, dark: 0x46_4647) + + /// A tag pill's text. + static let tagText = ThemeColor(light: 0x44_4444, dark: 0xDF_E0E0) +} diff --git a/apps/macos/Sources/Trace.swift b/apps/macos/Sources/Trace.swift new file mode 100644 index 000000000..9ceac51a2 --- /dev/null +++ b/apps/macos/Sources/Trace.swift @@ -0,0 +1,492 @@ +import Foundation +import os + +/// What the app records about its own work. +/// +/// Two sinks for the same intervals. `OSSignposter` always, so attaching +/// Instruments to any running instance shows them; and a line of JSON per event +/// to `/trace.jsonl` when a harness has pointed the app at a +/// directory, which is what the `debug_app_*` tools read back. +/// +/// The file is its own channel rather than stdout or stderr, because those two +/// are reported as deltas on every snapshot: a trace stream on either would bury +/// what AppKit had to say under the app's own instrumentation. +/// +/// With `JP_DEBUG_STATE_DIR` unset nothing is opened and no file is created, and +/// the only cost left is a signpost and a timestamp per interval. +enum Trace { + /// The trace file, inside the debug state directory. + static let fileName = "trace.jsonl" + + /// What an event is attributed to when the caller names nothing more + /// specific. + static let defaultTarget = "JP" + + /// Where the JSON goes, or `nil` when the app was launched as it ships. + /// + /// Resolved once. A harness sets the variable before launch and never + /// changes it, and re-reading the environment per event would cost more than + /// writing the line. + private static let sink = TraceWriter(directory: DebugState.directory, fileName: fileName) + + /// The signpost stream Instruments shows. + /// + /// Named for the app rather than for the slot a driven copy runs under, so + /// every instance appears under one subsystem. + static let signposter = OSSignposter( + subsystem: "computer.jp.jean-pierre", category: "trace") + + /// The signpost every interval is filed under. + /// + /// `OSSignposter` takes a `StaticString`, which an interval's name is not, so + /// the name travels in the signpost's message instead. + static let signpostName: StaticString = "interval" + + /// Whether events are being written to a file. + static var isRecording: Bool { sink != nil } + + /// Where the file is, once there is one. + static var url: URL? { sink?.url } + + /// Record one event. + static func event( + _ message: String, + target: String = defaultTarget, + level: TraceLevel = .info, + fields: TraceFields = [], + spans: [String] = [] + ) { + guard isRecording else { return } + + let line = line( + timestamp: timestamp(Date()), + level: level, + target: target, + message: message, + fields: fields, + spans: spans + ) + + guard let line else { return } + write(line) + } + + /// Append a line that has already been built. + /// + /// For a caller assembling its own lines, such as one turning durations + /// reported from elsewhere into events. ``event(_:target:level:fields:spans:)`` + /// is the ordinary way in. + static func write(_ line: String) { + sink?.append(line) + } + + /// Start timing a piece of work, to be ended through the returned token. + /// + /// `fields` are written when the interval ends, before the ones `end` is + /// given, so an interval's own context reads ahead of its result. + static func interval( + _ name: String, + target: String = defaultTarget, + fields: TraceFields = [], + spans: [String] = [] + ) -> TraceInterval { + TraceInterval( + name: name, + target: target, + fields: fields, + spans: spans, + started: mach_absolute_time(), + signpost: signposter.beginInterval( + signpostName, id: signposter.makeSignpostID(), "\(name, privacy: .public)") + ) + } + + /// Run `work` as an interval named `name`, and return what it produced. + static func measuring( + _ name: String, + target: String = defaultTarget, + fields: TraceFields = [], + spans: [String] = [], + _ work: () -> T + ) -> T { + let token = interval(name, target: target, fields: fields, spans: spans) + let value = work() + token.end() + return value + } + + /// Write the event an interval ends with. + /// + /// The footprint is sampled here rather than in ``TraceInterval/end(_:)`` so + /// an app running without a state directory never makes the call. + static func record(_ interval: TraceInterval, elapsed ticks: UInt64, extra: TraceFields) { + guard isRecording else { return } + + var fields: TraceFields = [("duration_ms", .double(milliseconds(ticks)))] + fields.append(contentsOf: interval.fields) + fields.append(contentsOf: extra) + if let footprint = footprintMB() { + fields.append(("footprint_mb", .int(footprint))) + } + + event( + interval.name, + target: interval.target, + fields: fields, + spans: interval.spans + ) + } + + /// Record the pair that lines this timeline up with one measured on the mach + /// clock. + /// + /// A trace taken in Instruments carries mach timestamps and no wall clock; + /// this file carries wall clocks and no mach timestamps. One reading of both + /// at the same instant is what lets the two be laid over each other. + static func origin() { + let now = Date() + let ticks = mach_absolute_time() + let timebase = MachTimebase.current + + event( + "trace.origin", + target: "JP.Trace", + fields: [ + ("mach_absolute_time", .int(Int(clamping: ticks))), + ("unix_time_ns", .int(Int(now.timeIntervalSince1970 * 1_000_000_000))), + ("timebase_numer", .int(Int(timebase.numerator))), + ("timebase_denom", .int(Int(timebase.denominator))), + ] + ) + } + + /// One event as the line that goes in the file, or `nil` if it cannot be + /// encoded. + static func line( + timestamp: String, + level: TraceLevel, + target: String, + message: String, + fields: TraceFields, + spans: [String] + ) -> String? { + var all: TraceFields = [("message", .string(message))] + all.append(contentsOf: fields) + + return TraceLine( + timestamp: timestamp, + level: level, + target: target, + fields: all, + spans: spans + ).encoded() + } + + /// `date` as RFC 3339 in UTC, to the microsecond. + /// + /// Formatted by hand because `ISO8601DateFormatter` stops at milliseconds, + /// and because a formatter is a reference type that would have to be shared + /// across every thread that ends an interval. + static func timestamp(_ date: Date) -> String { + let seconds = date.timeIntervalSince1970 + let whole = seconds.rounded(.down) + var epoch = time_t(whole) + var parts = tm() + gmtime_r(&epoch, &parts) + + // Rounded, not truncated: a `Date` holds seconds as a `Double`, and the + // microsecond a caller put in comes back a fraction of a microsecond + // short of itself. + let micros = min(Int(((seconds - whole) * 1_000_000).rounded()), 999_999) + + return String( + format: "%04d-%02d-%02dT%02d:%02d:%02d.%06dZ", + parts.tm_year + 1900, + parts.tm_mon + 1, + parts.tm_mday, + parts.tm_hour, + parts.tm_min, + parts.tm_sec, + micros + ) + } + + /// A span of `mach_absolute_time()` ticks in milliseconds, to the + /// microsecond. + static func milliseconds(_ ticks: UInt64) -> Double { + let nanoseconds = Double(MachTimebase.current.nanoseconds(ticks)) + return (nanoseconds / 1000).rounded() / 1000 + } + + /// What the process currently occupies, in MiB. + /// + /// `phys_footprint` is the number macOS itself judges a process by, and the + /// call to read it costs microseconds. Which call site allocated the bytes is + /// a different question, and needs a tool that costs several times the run. + static func footprintMB() -> Int? { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t( + MemoryLayout.size / MemoryLayout.size) + + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + + guard result == KERN_SUCCESS else { return nil } + return Int(clamping: info.phys_footprint / (1024 * 1024)) + } +} + +extension Trace { + /// The launch, held from the app's earliest code until its first window. + /// + /// Main-actor state rather than locked state: both ends of this particular + /// interval run on the main actor, and nothing else touches it. + @MainActor private static var launch: TraceInterval? + + /// Start the launch interval, and record the clock origin. + @MainActor + static func beginLaunch() { + origin() + launch = interval("app.launch", target: "JP.App") + } + + /// End the launch interval, if it is still open. + /// + /// Called by every window as it appears, and only the first one finds an + /// interval to end. + @MainActor + static func endLaunch() { + launch?.end() + launch = nil + } +} + +/// A started interval, ended by whoever holds it. +struct TraceInterval { + /// What the interval is called, written as the event's message. + let name: String + + /// What the event is attributed to. + let target: String + + /// Context written ahead of whatever `end` is given. + let fields: TraceFields + + /// The enclosing interval names, root first. + let spans: [String] + + /// When it started, on the mach clock. + let started: UInt64 + + /// The signpost half of the same interval. + let signpost: OSSignpostIntervalState + + /// Close the interval, writing how long it took and what the process now + /// occupies. + func end(_ extra: TraceFields = []) { + let elapsed = mach_absolute_time() &- started + Trace.signposter.endInterval(Trace.signpostName, signpost) + Trace.record(self, elapsed: elapsed, extra: extra) + } +} + +/// Severity, spelled as the trace format spells it. +enum TraceLevel: String, Sendable { + case trace = "TRACE" + case debug = "DEBUG" + case info = "INFO" + case warn = "WARN" + case error = "ERROR" +} + +/// What a trace field can hold. +enum TraceValue: Encodable, Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .int(let value): try container.encode(value) + case .double(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + } + } +} + +extension TraceValue: ExpressibleByStringLiteral { + init(stringLiteral value: String) { self = .string(value) } +} + +extension TraceValue: ExpressibleByIntegerLiteral { + init(integerLiteral value: Int) { self = .int(value) } +} + +extension TraceValue: ExpressibleByFloatLiteral { + init(floatLiteral value: Double) { self = .double(value) } +} + +extension TraceValue: ExpressibleByBooleanLiteral { + init(booleanLiteral value: Bool) { self = .bool(value) } +} + +/// The fields of one event, in the order they are written. +/// +/// An array rather than a dictionary because the order is part of what makes a +/// line readable, and because a pinned test compares the whole string. +typealias TraceFields = [(String, TraceValue)] + +/// One event, shaped as `tracing-subscriber::fmt::json()` writes it. +/// +/// `jp` writes this format under `JP_DEBUG=1` and the tooling already parses it, +/// so the app's timeline and jp's can be read together. +struct TraceLine { + let timestamp: String + let level: TraceLevel + let target: String + let fields: TraceFields + let spans: [String] + + /// The line as it goes in the file, or `nil` if a value cannot be encoded. + /// + /// Assembled key by key rather than handed to `JSONEncoder` whole, because a + /// keyed container writes its entries in an order Foundation chooses: the + /// timestamp lands in the middle, and a duration ahead of the message it + /// belongs to. Every scalar still goes through the encoder, so escaping is + /// Foundation's job and not this file's. + func encoded() -> String? { + let encoder = JSONEncoder() + + guard + let level = Self.encode(.string(level.rawValue), with: encoder), + let target = Self.encode(.string(target), with: encoder), + let timestamp = Self.encode(.string(timestamp), with: encoder), + let fields = encodedFields(with: encoder) + else { + return nil + } + + var line = "{\"timestamp\":\(timestamp),\"level\":\(level),\"target\":\(target)," + line.append("\"fields\":\(fields)") + + // Omitted when empty: the parser treats the key as optional, and most + // events are not nested inside anything. + if !spans.isEmpty, let spans = encodedSpans(with: encoder) { + line.append(",\"spans\":\(spans)") + } + + line.append("}") + return line + } + + private func encodedFields(with encoder: JSONEncoder) -> String? { + var entries: [String] = [] + entries.reserveCapacity(fields.count) + + for (name, value) in fields { + guard + let name = Self.encode(.string(name), with: encoder), + let value = Self.encode(value, with: encoder) + else { + return nil + } + + entries.append("\(name):\(value)") + } + + return "{\(entries.joined(separator: ","))}" + } + + private func encodedSpans(with encoder: JSONEncoder) -> String? { + var entries: [String] = [] + entries.reserveCapacity(spans.count) + + for span in spans { + guard let name = Self.encode(.string(span), with: encoder) else { return nil } + entries.append("{\"name\":\(name)}") + } + + return "[\(entries.joined(separator: ","))]" + } + + /// One value as its JSON representation. + private static func encode(_ value: TraceValue, with encoder: JSONEncoder) -> String? { + guard let data = try? encoder.encode(value) else { return nil } + return String(decoding: data, as: UTF8.self) + } +} + +/// The ratio turning `mach_absolute_time()` ticks into nanoseconds. +struct MachTimebase: Sendable { + let numerator: UInt32 + let denominator: UInt32 + + /// What this machine reports, read once. + static let current: MachTimebase = { + var info = mach_timebase_info_data_t() + mach_timebase_info(&info) + return MachTimebase(numerator: info.numer, denominator: info.denom) + }() + + func nanoseconds(_ ticks: UInt64) -> UInt64 { + ticks * UInt64(numerator) / UInt64(denominator) + } +} + +/// An append-only line sink, writable from any isolation domain. +/// +/// `@unchecked Sendable` rather than an actor: an interval ends wherever the +/// work it timed ends, and an actor would put an `await` at every one of those +/// call sites, changing the timing being measured. The file handle is only ever +/// touched with `lock` held, which is what makes the unchecked claim true. +final class TraceWriter: @unchecked Sendable { + /// The file being appended to. + let url: URL + + private let lock = NSLock() + private let handle: FileHandle + + /// Open `fileName` inside `directory`, creating both if they are missing. + /// + /// `nil` when no directory is given, which is how the app ships: nothing is + /// created and nothing is written. + init?(directory: URL?, fileName: String) { + guard let directory else { return nil } + + let manager = FileManager.default + let url = directory.appendingPathComponent(fileName) + let path = url.path(percentEncoded: false) + + try? manager.createDirectory(at: directory, withIntermediateDirectories: true) + if !manager.fileExists(atPath: path) { + guard manager.createFile(atPath: path, contents: nil) else { return nil } + } + + guard let handle = try? FileHandle(forWritingTo: url) else { return nil } + _ = try? handle.seekToEnd() + + self.url = url + self.handle = handle + } + + deinit { + try? handle.close() + } + + /// Append `line` and a newline. + /// + /// A failed write is dropped rather than reported: the app is being observed, + /// not driven by this, and a full disk is not a reason to interrupt what the + /// person at the keyboard is reading. + func append(_ line: String) { + lock.withLock { + try? handle.write(contentsOf: Data("\(line)\n".utf8)) + } + } +} diff --git a/apps/macos/Sources/TranscriptDocument.swift b/apps/macos/Sources/TranscriptDocument.swift new file mode 100644 index 000000000..78579e0fc --- /dev/null +++ b/apps/macos/Sources/TranscriptDocument.swift @@ -0,0 +1,81 @@ +import AppKit + +/// A conversation's turns, as one piece of attributed text. +/// +/// One string rather than one view per message, because a text view lays out +/// what its viewport needs and re-wraps incrementally, where a stack of views +/// each measure and wrap themselves and a width change costs the sum of them. +/// +/// Turn boundaries are drawn as space rather than as a rule: the gap above the +/// first message of a turn is wider than the gap between two messages inside +/// one, which is what separates them. +enum TranscriptDocument { + /// `turns` laid out for reading, oldest first. + /// + /// Empty when there is nothing to show, which a caller distinguishes from a + /// conversation it could not read. + static func attributed( + _ turns: [ConversationTurn], style: MarkdownStyle + ) -> NSAttributedString { + let document = NSMutableAttributedString() + + for turn in turns { + for (offset, event) in turn.events.enumerated() { + // The gap belongs above the speaker's name rather than below the + // message before it, so all of the spacing is decided in one + // place and none of it has to reach back into text the markdown + // renderer has already styled. + let above: CGFloat = + if document.length == 0 { 0 } else if offset == 0 { style.turnSpacing } else + { style.eventSpacing } + + document.append(speaker(event.speaker, above: above, style: style)) + append(Markdown.attributed(event.text, style: style), to: document) + } + } + + // Every message ends with the newline separating it from the next, so + // the last one leaves a trailing empty line. + if document.length > 0 { + document.deleteCharacters(in: NSRange(location: document.length - 1, length: 1)) + } + + return document + } + + /// Who is speaking, as the line above what they said. + private static func speaker( + _ name: String, above: CGFloat, style: MarkdownStyle + ) -> NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.paragraphSpacingBefore = above + paragraph.paragraphSpacing = 2 + + return NSAttributedString( + string: "\(name)\n", + attributes: [ + .font: NSFont.systemFont(ofSize: style.body.pointSize - 2, weight: .semibold), + .foregroundColor: style.secondary, + .paragraphStyle: paragraph, + ] + ) + } + + /// Append `message` and the newline that ends it. + /// + /// The newline carries the message's own trailing attributes, so it sits on + /// the same paragraph rather than opening an unstyled one of the default + /// font's height. + private static func append( + _ message: NSAttributedString, to document: NSMutableAttributedString + ) { + document.append(message) + + let attributes = + message.length > 0 + ? message.attributes(at: message.length - 1, effectiveRange: nil) + : [:] + + document.append(NSAttributedString(string: "\n", attributes: attributes)) + } +} diff --git a/apps/macos/Sources/TranscriptTextView.swift b/apps/macos/Sources/TranscriptTextView.swift new file mode 100644 index 000000000..db057346a --- /dev/null +++ b/apps/macos/Sources/TranscriptTextView.swift @@ -0,0 +1,382 @@ +import AppKit +import SwiftUI + +/// A text view that reports what a window drag asked of it. +/// +/// Whether the text re-wraps while the window is still moving is the difference +/// between the transcript feeling native and feeling like a screenshot that +/// catches up. It depends on the layout stack, and the two fail differently +/// enough that the count of frames the drag delivered is worth having either +/// way: a stale transcript with a high count is layout refusing to run, and a +/// stale transcript with a count of zero is AppKit serving cached pixels +/// instead of resizing the view at all. +private final class LiveWrappingTextView: NSTextView { + /// How many frames of the current drag changed this view's size at all. + private var frames = 0 + + /// How many of those changed its *width*. + /// + /// The one that matters: a container only re-wraps when the width it tracks + /// moves. A drag that delivers hundreds of frames of pure height change + /// would leave the text correctly un-re-wrapped, and counting frames alone + /// could not tell that apart from layout refusing to run. + private var widthChanges = 0 + + /// The width this view had when the drag began. + private var widthAtStart: CGFloat = 0 + + /// How many frames of the drag changed the text *container's* width. + /// + /// The link between a resized view and re-wrapped text. A container tracking + /// the view is supposed to follow it, and a container whose geometry changes + /// is what invalidates layout — so a view width that moves while this stays + /// still is the whole bug, and one that moves in step with it puts the fault + /// after this point. + private var containerChanges = 0 + + /// The container width seen at the previous frame. + private var lastContainerWidth: CGFloat = 0 + + /// The least of the document the layout manager had laid out at any frame + /// of the drag, as a character index. + /// + /// Contiguous layout fills from the start, so this is how far down the + /// document layout reached at its worst. The minimum rather than the last + /// value, because the last frame of a drag is the one most likely to have + /// caught up. + private var laidOutTo = Int.max + + /// How many times AppKit asked this view to draw during the drag. + private var draws = 0 + + /// The tallest rectangle AppKit asked it to draw, in points. + /// + /// Compared against the height of what is on screen. A number far short of + /// that is AppKit redrawing a strip and keeping the rest, which is what a + /// view is told to expect when it says its content survives a resize. + private var tallestDraw: CGFloat = 0 + + /// Whether AppKit may keep what this view already drew when it resizes. + /// + /// Overridden to `false`. Left to itself the answer is yes, and then a + /// narrowing drag exposes no new region, so nothing is marked dirty and the + /// cached pixels are simply clipped — the text underneath has re-wrapped + /// and nobody has been asked to draw it. + /// + /// The cost is redrawing the visible text on every frame of a drag, which + /// is the work being watched anyway. + override var preservesContentDuringLiveResize: Bool { false } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard inLiveResize else { return } + + draws += 1 + tallestDraw = max(tallestDraw, dirtyRect.height) + } + + override func viewWillStartLiveResize() { + super.viewWillStartLiveResize() + + frames = 0 + widthChanges = 0 + draws = 0 + tallestDraw = 0 + laidOutTo = Int.max + containerChanges = 0 + widthAtStart = frame.width + lastContainerWidth = textContainer?.size.width ?? 0 + } + + override func setFrameSize(_ newSize: NSSize) { + let before = frame.width + super.setFrameSize(newSize) + guard inLiveResize else { return } + + frames += 1 + if newSize.width != before { + widthChanges += 1 + } + + // The width a tracking container would take, handed to it directly. + // + // A text view passes its width to the container it is tracked by, and + // does not do it while a resize is in progress: the container keeps the + // width the drag started from until the mouse comes up. Nothing then + // changes the container's geometry, nothing invalidates layout, and the + // view faithfully redraws lines wrapped to a width the window no longer + // has. + // + // Setting it here is what a tracking container would have done, one + // frame earlier. The inset is counted twice because it applies to both + // edges. + if let container = textContainer { + let wanted = newSize.width - textContainerInset.width * 2 + if container.size.width != wanted { + container.size = NSSize(width: wanted, height: container.size.height) + } + } + + let containerWidth = textContainer?.size.width ?? 0 + if containerWidth != lastContainerWidth { + containerChanges += 1 + lastContainerWidth = containerWidth + } + + // Only on TextKit 2, which lays out around the viewport and leaves the + // rest estimated. Contiguous layout has no viewport to nudge. + // + // Asked of `textLayoutManager` rather than of the stack constant, + // because reading it is the one probe that answers which stack this + // view is on without moving it to the other one. + if let viewport = textLayoutManager?.textViewportLayoutController { + viewport.layoutViewport() + } else { + laidOutTo = min(laidOutTo, layoutManager?.firstUnlaidCharacterIndex() ?? -1) + } + } + + override func viewDidEndLiveResize() { + super.viewDidEndLiveResize() + + let visible = enclosingScrollView?.documentVisibleRect ?? .zero + + Trace.event( + "transcript.liveresize", + target: "JP.Transcript", + fields: [ + ("frames", .int(frames)), + ("width_changes", .int(widthChanges)), + ("container_changes", .int(containerChanges)), + ("container_width", .double(Double(textContainer?.size.width ?? 0))), + ("tracks_width", .bool(textContainer?.widthTracksTextView ?? false)), + ("draws", .int(draws)), + ("tallest_draw", .double(Double(tallestDraw))), + ("visible_height", .double(Double(visible.height))), + ("width_from", .double(Double(widthAtStart))), + ("width_to", .double(Double(frame.width))), + ("laid_out_to", .int(laidOutTo == Int.max ? -1 : laidOutTo)), + ("characters", .int(textStorage?.length ?? 0)), + ("visible_from_y", .double(Double(visible.minY))), + ("document_height", .double(Double(frame.height))), + ] + ) + } +} + +/// The transcript, drawn by one text view. +/// +/// The document is built here rather than handed in, so it is rebuilt only when +/// the conversation or the appearance changes — not on every layout pass, and +/// not on every frame of a window resize. +struct TranscriptTextView: NSViewRepresentable { + /// Which conversation is on screen, and the cheap half of deciding whether + /// the document has to be rebuilt. + let conversationID: String? + + /// What to draw, oldest turn first. + let turns: [ConversationTurn] + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> NSScrollView { + let textView = LiveWrappingTextView(usingTextLayoutManager: Self.usesTextKit2) + Self.configure(textView) + + textView.setAccessibilityIdentifier(AccessibilityID.Transcript.text) + + let scroll = NSScrollView() + scroll.documentView = textView + scroll.hasVerticalScroller = true + scroll.drawsBackground = false + scroll.setAccessibilityIdentifier(AccessibilityID.Transcript.scroll) + + context.coordinator.watchForLayoutManagerDowngrade(of: textView) + + return scroll + } + + /// Set a text view up to draw a transcript. + /// + /// Separate from ``makeNSView(context:)`` so it can be checked without a + /// SwiftUI host: several of these settings are the difference between a + /// transcript that behaves and one that looks right and does not. + static func configure(_ textView: NSTextView) { + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + // The SwiftUI background behind this view is the one the design calls + // for; AppKit's would paint over it. + textView.drawsBackground = false + textView.textContainerInset = NSSize(width: Self.margin, height: Self.margin) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + + // Width from the view, height unbounded: the container re-wraps as the + // window is resized and grows downwards as far as the document needs. + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: 0, height: CGFloat.greatestFiniteMagnitude) + // The document's own margin is `textContainerInset`; this would add five + // more points inside every line fragment. + textView.textContainer?.lineFragmentPadding = 0 + + // The cursor, and nothing else. + // + // This dictionary is what AppKit merges over a `.link` range as it draws, + // and it is also the whole mechanism behind the pointing hand: the default + // carries `.cursor` alongside a colour and an underline. Emptying it to + // keep the document's own colour takes the cursor with it, and a link that + // does not change the pointer does not read as a link. + textView.linkTextAttributes = [.cursor: NSCursor.pointingHand] + + layOutContiguously(textView) + } + + func updateNSView(_ scroll: NSScrollView, context: Context) { + guard let textView = scroll.documentView as? NSTextView else { return } + + let appearance = textView.effectiveAppearance + guard + context.coordinator.needsDocument( + for: conversationID, turnCount: turns.count, appearance: appearance) + else { return } + + // A colour is resolved into the document as it is built rather than each + // time it is drawn, so a window moved between appearances rebuilds. + let style = MarkdownStyle.reading(in: appearance) + let document = Trace.measuring( + "transcript.render", + target: Self.traceTarget, + fields: [("turn_count", .int(turns.count))] + ) { + TranscriptDocument.attributed(turns, style: style) + } + + textView.textStorage?.setAttributedString(document) + } + + /// What the transcript's events are attributed to. + private static let traceTarget = "JP.Transcript" + + /// The space between the text and the edges of the pane. + private static let margin: CGFloat = 24 + + /// Which layout stack the text view runs on. + /// + /// TextKit 1, bought deliberately and not cheaply. + /// + /// TextKit 2 lays out around the viewport and estimates the rest, which is + /// what a long document wants and is measurably faster here: the same ten + /// programmatic resizes cost 155 samples against this stack's 438 on a + /// 29-event conversation, and 355 against 412 on a 167-event one. TextKit 2 + /// scales with the document where this is flat, so the gap narrows as + /// conversations grow, but at these sizes it is behind. + /// + /// What contiguous layout buys is an exact document height, and so a scroll + /// bar that states the truth instead of an estimate that refines as it + /// scrolls and shifts the knob under the pointer. That was a stated goal, and + /// it is the reason for the trade. + /// + /// It is *not* what fixed re-wrapping during a window drag — that was the text + /// container not being told its new width, and it needed fixing on both + /// stacks. Switching here changes the scroll bar and the cost, nothing else. + private static let usesTextKit2 = false + + /// Ask a TextKit 1 view for an exact document height. + /// + /// Non-contiguous layout skips the ranges nobody is looking at, which is + /// faster to first paint and gives back an approximate total — the same + /// estimate, and so the same shifting scroll bar, that choosing this stack + /// was meant to avoid. Off, so the height is measured rather than guessed. + /// + /// Does nothing on TextKit 2, and asks in the order that keeps that true: + /// `textLayoutManager` reports which stack the view is on without changing + /// it, where reading `layoutManager` first would drag a TextKit 2 view down + /// to TextKit 1 permanently and silently. + private static func layOutContiguously(_ textView: NSTextView) { + guard textView.textLayoutManager == nil else { return } + + textView.layoutManager?.allowsNonContiguousLayout = false + } + + /// Per-view state that outlives a single layout pass. + @MainActor + final class Coordinator { + /// What the document currently in the text view was built from. + private var built: + (conversationID: String?, turnCount: Int, appearance: NSAppearance.Name)? + + /// Whether the document has to be rebuilt for this conversation and + /// appearance. + /// + /// The turn count stands in for the turns themselves, which would cost + /// a comparison of every message's text on a pass that happens on every + /// frame of a resize. It is enough because a window reads a conversation + /// once — turns written by a concurrent `jp query` are invisible until + /// the workspace is reopened — and because the view is rebuilt outright + /// when the conversation changes. + func needsDocument( + for conversationID: String?, turnCount: Int, appearance: NSAppearance + ) -> Bool { + let wanted = (conversationID, turnCount, appearance.name) + + guard let built else { + built = wanted + return true + } + + guard + built.conversationID == wanted.0, + built.turnCount == wanted.1, + built.appearance == wanted.2 + else { + self.built = wanted + return true + } + + return false + } + + /// Report a text view falling back to TextKit 1. + /// + /// The downgrade is silent, permanent for that view, and takes + /// viewport-driven layout with it — so a transcript that quietly became + /// slow at size would look like the layout work never helped rather + /// than like something switched it off. + func watchForLayoutManagerDowngrade(of textView: NSTextView) { + observer = NotificationCenter.default.addObserver( + forName: NSTextView.willSwitchToNSLayoutManagerNotification, + object: textView, + queue: .main + ) { _ in + Trace.event( + "transcript.textkit.downgrade", + target: "JP.Transcript", + level: .warn + ) + } + } + + /// The registration to undo when this coordinator goes away. + /// + /// `nonisolated(unsafe)` because `deinit` is not isolated and this is + /// not `Sendable`. Safe: it is written once while the view is being + /// made, on the main actor, and read once in `deinit` — which runs only + /// after the last reference to the coordinator is gone, so there is no + /// second access to race with. + private nonisolated(unsafe) var observer: (any NSObjectProtocol)? + + deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + } +} diff --git a/apps/macos/Sources/WindowButtons.swift b/apps/macos/Sources/WindowButtons.swift new file mode 100644 index 000000000..0fb1783c5 --- /dev/null +++ b/apps/macos/Sources/WindowButtons.swift @@ -0,0 +1,132 @@ +import AppKit +import SwiftUI + +/// Moves the close, minimize and zoom buttons down the window. +/// +/// macOS centres them 14 points below the top edge, which is the middle of a +/// standard title bar. A window with no title bar and a taller control in that +/// corner — a search field, say — leaves them sitting above that control's centre +/// rather than level with it. +/// +/// There is no supported way to ask for this. A title bar grows to fit a toolbar, +/// and a toolbar spans the whole window: it would put a strip of chrome above the +/// transcript, which is the thing having no title bar was for. So the buttons are +/// moved directly, and moved again whenever AppKit lays the title bar out afresh. +/// +/// Add it as a background of whatever the buttons should line up with: +/// +/// ```swift +/// SearchField(text: $query) +/// .background(WindowButtons.placed(leading: 18, centredOn: 24)) +/// ``` +enum WindowButtons { + /// A view that puts the window buttons `leading` points from the window's left + /// edge, centred `distance` points below its top. + /// + /// Draws nothing. Does nothing while `distance` is not a real measurement, so + /// a caller measuring the control can pass what it has before the first + /// layout without the buttons jumping to the top of the window. + static func placed(leading: CGFloat, centredOn distance: CGFloat) -> some View { + Mover(leading: leading, distance: distance) + } + + /// How far apart the buttons sit, centre to centre. + /// + /// What macOS itself uses, kept because the spacing is not what is being + /// changed here: measured off a running window, the three frames sit at 20 + /// point intervals. + static let spacing: CGFloat = 20 + + private struct Mover: NSViewRepresentable { + let leading: CGFloat + let distance: CGFloat + + func makeNSView(context: Context) -> NSView { + Probe() + } + + func updateNSView(_ view: NSView, context: Context) { + guard let probe = view as? Probe else { return } + probe.leading = leading + probe.distance = distance + probe.place() + } + } + + /// A view that does nothing but reposition its window's buttons. + private final class Probe: NSView { + /// Where the first button's frame belongs, from the window's left edge. + var leading: CGFloat = 0 + + /// Where the buttons' centre belongs, below the window's top edge. + var distance: CGFloat = 0 + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + observe() + place() + } + + /// The buttons a window puts in its top-left corner. + private static let kinds: [NSWindow.ButtonType] = [ + .closeButton, .miniaturizeButton, .zoomButton, + ] + + /// Put the buttons where ``distance`` says, if there are any to move. + /// + /// Each button's own height is what the centring is done against, rather + /// than a number written down here: they are 16 points tall today and that + /// is not this view's business. + func place() { + guard distance > 0, let window else { return } + + for (index, kind) in Self.kinds.enumerated() { + guard + let button = window.standardWindowButton(kind), + let container = button.superview + else { continue } + + // Absolute, not a shift: this runs again on every window layout, + // and nudging each button from wherever it currently is would walk + // them across the title bar. + let x = leading + CGFloat(index) * WindowButtons.spacing + + // The container is not flipped, so a larger `y` is higher up. + let y = container.bounds.height - distance - button.frame.height / 2 + guard x != button.frame.origin.x || y != button.frame.origin.y else { continue } + + button.setFrameOrigin(NSPoint(x: x, y: y)) + } + } + + /// Re-place the buttons whenever the window's own layout could have put + /// them back. + /// + /// A resize is the common one; entering full screen and leaving it again + /// rebuilds the title bar entirely. + private func observe() { + guard let window else { return } + + for name in [ + NSWindow.didResizeNotification, + NSWindow.didEnterFullScreenNotification, + NSWindow.didExitFullScreenNotification, + ] { + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidLayOut), + name: name, + object: window + ) + } + } + + @objc private func windowDidLayOut() { + place() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + } +} diff --git a/apps/macos/Sources/WorkspaceModel.swift b/apps/macos/Sources/WorkspaceModel.swift new file mode 100644 index 000000000..47c4a0d5d --- /dev/null +++ b/apps/macos/Sources/WorkspaceModel.swift @@ -0,0 +1,132 @@ +import Foundation + +/// What the conversation list has to show. +/// +/// One value rather than a set of separate properties, so a load result reaches +/// the view in a single mutation. Assigning several observed properties in a row +/// makes the list reload partway through its own update, which AppKit reports as +/// a reentrant `NSTableView` delegate call. +enum WorkspaceState: Equatable, Sendable { + /// A workspace is being read. + case loading + + /// The workspace's conversations, most recently active first. + case loaded([ConversationSummary]) + + /// There is nothing to list, and why. + case unavailable(title: String, detail: String) +} + +/// The open workspace and its conversation list. +/// +/// Loads once per opened workspace. Turns written by a concurrent `jp query` are +/// invisible until the workspace is reopened. +@MainActor +@Observable +final class WorkspaceModel { + /// What the conversation list has to show. + private(set) var state: WorkspaceState = .unavailable( + title: "No Workspace", + detail: "Choose File ▸ Open Workspace to browse a workspace." + ) + + /// The workspace being read, once one has been opened. + /// + /// The workspace this model was asked to open. + private(set) var path: String? + + /// The workspace that is actually open and ready to read. + /// + /// Distinct from ``path``, which is set the moment a workspace is *requested*. + /// A view that keyed a read on `path` would fire while the workspace was + /// still opening, find nothing, and never try again. + private(set) var openWorkspace: String? + + /// The workspace, held open for the life of the window. + private var session: WorkspaceSession? + + /// Open the workspace containing `path`, replacing whatever was open. + /// + /// `path` may be the workspace root or any directory inside it. + func open(_ path: String) async { + self.path = path + openWorkspace = nil + session = nil + state = .loading + + let timing = Trace.interval(Self.openSpan, target: Self.traceTarget) + let opened = await WorkspaceSession.open(path: path) + + // The window can close while a read is in flight. Its task is cancelled, + // but the read itself is not, so the result still arrives here. + guard !Task.isCancelled else { + timing.end([("cancelled", true)]) + return + } + + switch opened { + case .failure(let error): + state = .unavailable(title: "Could Not Open Workspace", detail: error.message) + timing.end([("failed", true)]) + + case .success(let session): + self.session = session + openWorkspace = path + + let conversations = await session.readConversations(spans: [Self.openSpan]) + + guard !Task.isCancelled else { + timing.end([("cancelled", true)]) + return + } + + state = Self.state(for: conversations) + timing.end([("conversation_count", .int((try? conversations.get())?.count ?? 0))]) + } + } + + /// What this model's events are attributed to. + private static let traceTarget = "JP.Workspace" + + /// The interval opening a workspace and listing it is recorded as. + private static let openSpan = "workspace.open" + + /// Read one conversation's turns from the open workspace. + /// + /// Reuses the open workspace rather than opening another: opening scans every + /// conversation directory in both storage roots, which is far too much work + /// to repeat every time somebody clicks a row. + /// `spans` names the intervals already open around this call, root first, so + /// the read is traced beneath the work that asked for it. + func events( + for conversationID: ConversationSummary.ID, + spans: [String] = [] + ) async -> Result<[ConversationTurn], WorkspaceError> { + guard let session else { + return .failure(WorkspaceError(message: "No workspace is open.")) + } + + return await session.readEvents(for: conversationID, spans: spans) + } + + /// The state a finished read leaves the list in. + private static func state( + for result: Result<[ConversationSummary], WorkspaceError> + ) -> WorkspaceState { + switch result { + case .success(let conversations) where conversations.isEmpty: + .unavailable( + title: "No Conversations", + detail: "This workspace has no conversations yet." + ) + // Already ordered most recently active first by the library, which is + // where that decision belongs: ordering timestamps needs them parsed, and + // every caller re-deriving it is how two views of one workspace end up + // disagreeing. + case .success(let conversations): + .loaded(conversations) + case .failure(let error): + .unavailable(title: "Could Not Read Workspace", detail: error.message) + } + } +} diff --git a/apps/macos/Sources/WorkspaceReader.swift b/apps/macos/Sources/WorkspaceReader.swift new file mode 100644 index 000000000..6b03bd266 --- /dev/null +++ b/apps/macos/Sources/WorkspaceReader.swift @@ -0,0 +1,243 @@ +import Foundation + +/// A conversation, as `jp_workspace_conversations` reports it. +/// +/// Hand-maintained to match `ConversationSummary` in the Rust `jp_plugin` crate. +/// Nothing checks that the two agree, so a field added there needs adding here +/// too; `ConversationSummaryTests` pins the payload this decodes from. +struct ConversationSummary: Decodable, Identifiable, Sendable, Equatable { + /// The conversation ID, as a decisecond timestamp in decimal. + let id: String + + /// The conversation title, absent until one has been generated or set. + let title: String? + + /// When the conversation was last activated, as RFC 3339 text. + /// + /// Deliberately unparsed. The Rust side emits a fractional-seconds part + /// whenever the stored timestamp has one, and `JSONDecoder`'s `.iso8601` + /// strategy rejects fractional seconds, so a `Date` here would decode the + /// whole-second case and fail on every real workspace. ``ConversationDate`` + /// is where the parsing happens, for the code that displays it. + let lastActivatedAt: String + + /// When the conversation was pinned, as RFC 3339 text, absent if it is not + /// pinned. + /// + /// Unparsed for the same reason as ``lastActivatedAt``, and nothing shows + /// the instant itself: what the sidebar needs is ``isPinned``. + let pinnedAt: String? + + /// How many events the conversation holds. + let eventsCount: Int + + /// Whether the conversation is pinned. + var isPinned: Bool { + pinnedAt != nil + } + + enum CodingKeys: String, CodingKey { + case id + case title + case lastActivatedAt = "last_activated_at" + case pinnedAt = "pinned_at" + case eventsCount = "events_count" + } +} + +/// One piece of work the library timed inside a single call. +/// +/// Hand-maintained to match `Span` in the Rust `jp_ffi` crate. Nothing checks +/// that the two agree; `WorkspaceReaderTests` decodes the exact payload that +/// crate's own tests pin. +struct LibrarySpan: Decodable, Sendable, Equatable { + /// What the work is called, written as the trace event's message. + let name: String + + /// How long it took, in milliseconds. + let durationMS: Double + + enum CodingKeys: String, CodingKey { + case name + case durationMS = "duration_ms" + } +} + +/// A failure reported by the Rust library, or by decoding its output. +struct WorkspaceError: LocalizedError, Sendable, Equatable { + let message: String + + var errorDescription: String? { message } +} + +/// An open JP workspace. +/// +/// Noncopyable, so the compiler enforces what the C contract requires: exactly +/// one owner of the handle, and exactly one `jp_workspace_close`. Copying this +/// would give two owners and a double free, which is a compile error rather than +/// a crash. +/// +/// Reading takes locks and touches the filesystem. Call it off the main thread or +/// the UI stalls behind a slow read. +struct WorkspaceReader: ~Copyable { + private let handle: OpaquePointer + + /// Open the workspace containing `path`, which may be the workspace root or + /// any directory inside it. + /// + /// Opening writes to disk: it creates the user-local conversation store if + /// missing and moves corrupt conversations aside, as `jp` does on startup. + init(path: String) throws(WorkspaceError) { + // Swift materializes a NUL-terminated buffer for the duration of the + // call, which is all `jp_workspace_open` requires of the pointer. + guard let handle = jp_workspace_open(path) else { + throw Self.lastError() + } + self.handle = handle + } + + deinit { + jp_workspace_close(handle) + } + + /// What a read is attributed to on the timeline. + /// + /// A target of its own, so time spent below this boundary reads as the + /// library's rather than the app's. + static let traceTarget = "JP.FFI" + + /// The interval a conversation-list read is recorded as. + static let conversationsSpan = "workspace.conversations" + + /// The interval an event read is recorded as. + static let eventsSpan = "workspace.events" + + /// Every conversation in the workspace, most recently active first. + /// + /// `spans` names the intervals already open around this call, root first. + /// The library's own timings are recorded beneath them, so a reader sees + /// where inside the app's work the library's time went. + borrowing func conversations( + spans: [String] = [] + ) throws(WorkspaceError) -> [ConversationSummary] { + let timing = Trace.interval( + Self.conversationsSpan, target: Self.traceTarget, spans: spans) + defer { timing.end() } + + // Asked for only while something is listening. Unrecorded, the library + // allocates no timings string and nothing here has one to release. + var timings: UnsafeMutablePointer? + let raw = + Trace.isRecording + ? jp_workspace_conversations(handle, &timings) + : jp_workspace_conversations(handle, nil) + + Self.record(timings, under: spans + [Self.conversationsSpan]) + + guard let raw else { + throw Self.lastError() + } + + let json = Self.take(raw) + do { + return try JSONDecoder().decode([ConversationSummary].self, from: json) + } catch { + throw WorkspaceError(message: "could not decode the conversation list: \(error)") + } + } + + /// Every event in a conversation, oldest first. + /// + /// `conversationID` is the `id` of a summary from ``conversations(spans:)``. + /// `spans` names the intervals already open around this call, root first. + borrowing func events( + for conversationID: String, + spans: [String] = [] + ) throws(WorkspaceError) -> [ConversationTurn] { + let timing = Trace.interval(Self.eventsSpan, target: Self.traceTarget, spans: spans) + defer { timing.end() } + + var timings: UnsafeMutablePointer? + let raw = + Trace.isRecording + ? jp_workspace_events(handle, conversationID, &timings) + : jp_workspace_events(handle, conversationID, nil) + + Self.record(timings, under: spans + [Self.eventsSpan]) + + guard let raw else { + throw Self.lastError() + } + + do { + return try JSONDecoder().decode([ConversationTurn].self, from: Self.take(raw)) + } catch { + throw WorkspaceError(message: "could not decode the event list: \(error)") + } + } + + /// Write what the library timed inside one call, nested under `enclosing`. + /// + /// `raw` is null when no timings were asked for, and when the library could + /// not build them. + private static func record( + _ raw: UnsafeMutablePointer?, + under enclosing: [String] + ) { + guard let raw else { return } + + for line in timingLines(take(raw), under: enclosing, at: Trace.timestamp(Date())) { + Trace.write(line) + } + } + + /// The trace lines the library's timings become, nested under `enclosing`. + /// + /// Built rather than written straight out, so a test can pin them: nesting + /// is the whole point of these events, and one written with an empty span + /// stack looks like any other line in the file. + /// + /// Every span of one call carries `timestamp`, because durations are all the + /// library reports. There is no second clock to place them on, and the order + /// they are written in is the order they ran. + /// + /// A payload that will not decode produces no lines. Instrumentation nobody + /// can read is not a reason to fail the read it was measuring. + static func timingLines( + _ json: Data, + under enclosing: [String], + at timestamp: String + ) -> [String] { + guard let spans = try? JSONDecoder().decode([LibrarySpan].self, from: json) else { + return [] + } + + return spans.compactMap { span in + Trace.line( + timestamp: timestamp, + level: .info, + target: traceTarget, + message: span.name, + fields: [("duration_ms", .double(span.durationMS))], + spans: enclosing + ) + } + } + + /// The library's message for the most recent failure on this thread. + private static func lastError() -> WorkspaceError { + guard let raw = jp_last_error() else { + return WorkspaceError(message: "the library reported a failure without a message") + } + return WorkspaceError(message: String(decoding: take(raw), as: UTF8.self)) + } + + /// Copy a string the library allocated, releasing the original. + /// + /// Rust frees what Rust allocates, so the bytes are copied out and the + /// pointer handed straight back. + private static func take(_ raw: UnsafeMutablePointer) -> Data { + defer { jp_string_free(raw) } + return Data(bytes: raw, count: strlen(raw)) + } +} diff --git a/apps/macos/Sources/WorkspaceSession.swift b/apps/macos/Sources/WorkspaceSession.swift new file mode 100644 index 000000000..d2041a0a9 --- /dev/null +++ b/apps/macos/Sources/WorkspaceSession.swift @@ -0,0 +1,76 @@ +import Foundation + +/// A workspace held open, off the main actor. +/// +/// Opening a workspace scans every conversation directory in both storage roots, +/// so it happens once per workspace rather than once per read. Reads are +/// serialized by the actor, which also keeps the reader — a noncopyable value +/// that cannot cross an isolation boundary — in one place. +actor WorkspaceSession { + private let reader: WorkspaceReader + + /// Open the workspace containing `path`. + /// + /// `path` may be the workspace root or any directory inside it. + init(path: String) throws(WorkspaceError) { + reader = try WorkspaceReader(path: path) + } + + /// Every conversation in the workspace. + /// + /// `spans` names the intervals already open around this call, root first, so + /// the read is traced beneath the work that asked for it. + func conversations(spans: [String] = []) throws(WorkspaceError) -> [ConversationSummary] { + try reader.conversations(spans: spans) + } + + /// One conversation's events, oldest first. + /// + /// `spans` names the intervals already open around this call, root first. + func events( + for conversationID: String, + spans: [String] = [] + ) throws(WorkspaceError) -> [ConversationTurn] { + try reader.events(for: conversationID, spans: spans) + } +} + +extension WorkspaceSession { + /// Open the workspace at `path`, off the main actor. + static func open(path: String) async -> Result { + let opened = Task.detached { () -> Result in + do throws(WorkspaceError) { + return .success(try WorkspaceSession(path: path)) + } catch { + return .failure(error) + } + } + + return await opened.value + } + + /// Read every conversation, returning the failure rather than throwing so a + /// caller can put it on screen. + func readConversations( + spans: [String] = [] + ) async -> Result<[ConversationSummary], WorkspaceError> { + do throws(WorkspaceError) { + return .success(try conversations(spans: spans)) + } catch { + return .failure(error) + } + } + + /// Read one conversation's events, returning the failure rather than + /// throwing. + func readEvents( + for conversationID: String, + spans: [String] = [] + ) async -> Result<[ConversationTurn], WorkspaceError> { + do throws(WorkspaceError) { + return .success(try events(for: conversationID, spans: spans)) + } catch { + return .failure(error) + } + } +} diff --git a/apps/macos/Sources/WorkspaceWindow.swift b/apps/macos/Sources/WorkspaceWindow.swift new file mode 100644 index 000000000..cd8ba7098 --- /dev/null +++ b/apps/macos/Sources/WorkspaceWindow.swift @@ -0,0 +1,527 @@ +import SwiftUI + +/// One workspace, in one window. +/// +/// Owns its model, so each window reads its own workspace and windows can be +/// tabbed together or pulled apart without sharing state. +struct WorkspaceWindow: View { + /// The workspace this window shows, restored when the window reopens. + /// + /// Per window rather than per app: two windows on two workspaces is the whole + /// point of having windows. + @SceneStorage("workspacePath") private var workspacePath: String? + + /// Whether this window's directory chooser is on screen. + @State private var isChoosingWorkspace = false + + @State private var model = WorkspaceModel() + @Environment(RecentWorkspaces.self) private var recents + + /// The selected conversation. + /// + /// Plain state, mirrored to ``storedSelection`` rather than bound directly to + /// it: a `List` writes its selection binding while it is handling the click, + /// and scene storage persists on write, which puts a view update inside the + /// table view's own update. + @State private var selection: String? + + /// The selected conversation as the window last had it, restored on reopen. + @SceneStorage("selectedConversation") private var storedSelection: String? + + /// What the filter box holds. + /// + /// Not persisted. A filter is a way of looking at the list right now, and a + /// window that reopened onto a list mysteriously missing most of its rows + /// would be a bug report. + @State private var query = "" + + /// Stable identity for this window, for as long as it exists. + /// + /// Only the menu actions use it, and only so a republished + /// ``WorkspaceActions`` from this window compares equal to the last one. + @State private var windowID = UUID() + + /// Whether the sidebar is showing. + /// + /// Written only when View ▸ Hide Sidebar is chosen, so it is bound straight to + /// scene storage: a window comes back with the sidebar it was closed with. + @SceneStorage("sidebarVisible") private var isSidebarVisible = true + + /// How wide the sidebar is. + /// + /// Plain state, mirrored to ``storedSidebarWidth`` when a drag ends rather than + /// bound to it: scene storage persists on every write, and a drag writes on + /// every frame it is dragged through. + @State private var sidebarWidth = Self.defaultSidebarWidth + + /// The sidebar's width as the window last had it, restored on reopen. + @SceneStorage("sidebarWidth") private var storedSidebarWidth: Double? + + /// When the conversations on screen were read. + /// + /// What the rows date themselves against. Fixed at the moment of the read + /// rather than taken fresh per render, because a render happens on every frame + /// of a divider drag and a clock reading that changes each time would make the + /// list unequal to itself and undo the skipping that keeps the drag smooth. + /// + /// The cost is that "21 minutes ago" is 21 minutes after the workspace was + /// opened, not after now. + @State private var listingReadAt = Date() + + /// How tall the search field turned out to be. + /// + /// Measured because it follows the field's font rather than a number this view + /// chooses, and the window buttons are centred against it. Zero until the first + /// layout, which leaves the buttons where macOS put them. + @State private var searchFieldHeight: CGFloat = 0 + + /// Whether the conversation list is scrolled away from its top. + /// + /// Only decides whether a line is drawn above the first row, and only changes + /// when the list leaves or returns to the top rather than as it scrolls. + @State private var isListScrolled = false + + /// The width the sidebar was at when the current drag started. + /// + /// A drag reports its translation from where it began, so resizing needs the + /// width it began from. Nil between drags. + @State private var dragStartWidth: Double? + + @Environment(\.openWindow) private var openWindow + + var body: some View { + // The whole body, because what it costs is what a window costs to + // re-render, and the rows underneath are not instrumented: a transcript + // realizes thousands of them, and a line per row would be a trace nobody + // can read of a run nobody can time. + Trace.measuring("WorkspaceWindow.body", target: Self.traceTarget) { + content + } + } + + /// What the window shows, timed by ``body``. + private var content: some View { + let listing = self.listing + + return HStack(spacing: 0) { + if isSidebarVisible { + sidebar(listing) + .frame(width: sidebarWidth) + + splitDivider + // Up into the title bar's strip, which the sidebar beside it + // already fills. Without this the line starts below the strip + // and the window's own title bar colour shows through above it. + .ignoresSafeArea(.container, edges: .top) + // Above both panes, for hit testing as much as for drawing. + // + // The grab strip is wider than the line and hangs over the pane + // on either side. Later siblings in a stack are in front, so + // without this the transcript covers the half of the strip on + // its side: approaching the divider from the sidebar worked and + // approaching it from the transcript did nothing at all — + // neither the cursor nor the drag. + .zIndex(1) + } + + ConversationHistoryView(model: model, conversationID: selection) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + // What keeps the window on screen at all. A window is sized from its + // content, and content that states only maxima has an ideal size of + // nothing: the window collapses, and a collapsed window is absent from the + // window server's list rather than merely small. Held here rather than left + // to the scene, because the scene's `defaultSize` applies to a window + // opened fresh and not to one restored into a saved frame. + .frame(minWidth: Self.minimumWindowWidth, minHeight: Self.minimumWindowHeight) + // Carried but not displayed: the window has no title bar to show it in. + // It is still what the Window menu lists the window under, and what an + // external driver addresses it by. + .navigationTitle(title) + // A driven copy of the app can acquire a Space of its own, and a window + // one Space away is absent from the accessibility tree rather than merely + // off screen. Nothing outside a debug run. + .background(DebugSpaces.joinEverySpace()) + // Controls that tint pick this up, which is most of what makes the + // window look like one app rather than a themed list beside a stock one. + .tint(Theme.accent.color) + .onAppear { Trace.endLaunch() } + .task(id: workspacePath) { + // Restored before the list exists. Setting it afterwards would + // change the list's selection during the list's own update. + selection = storedSelection + sidebarWidth = storedSidebarWidth ?? Self.defaultSidebarWidth + await load() + listingReadAt = Date() + } + .onChange(of: selection) { _, new in storedSelection = new } + // Offers the File menu this window, so ⌘O and Open Recent act on whichever + // window is in front rather than on the app as a whole. + .focusedSceneValue( + \.workspaceActions, + WorkspaceActions( + windowID: windowID, + hasSelection: selection != nil, + isSidebarVisible: isSidebarVisible, + choose: { isChoosingWorkspace = true }, + open: { show($0) }, + copyLinks: { copyLinks(for: selectedIDs, among: listing?.all ?? []) }, + toggleSidebar: { isSidebarVisible.toggle() } + ) + ) + .fileImporter(isPresented: $isChoosingWorkspace, allowedContentTypes: [.folder]) { + result in + guard case .success(let url) = result else { return } + show(url) + } + } + + /// The line between the sidebar and the transcript, and the handle that + /// resizes them. + /// + /// Drawn by the window rather than by a `NavigationSplitView`, which was what + /// held these two panes before. `NSSplitView` draws a translucent divider over + /// whatever is behind it and offers no way to change either the colour or the + /// width, so the line came out two pixels of two different greys that shifted + /// with the content underneath. This one is the colour it is told to be. + /// + /// The grab area is wider than the line, because two points is not something a + /// person can reliably hit. + private var splitDivider: some View { + Rectangle() + .fill(Theme.paneDivider.color) + .frame(width: Self.dividerWidth) + .overlay { + Rectangle() + .fill(.clear) + .contentShape(.rect) + .frame(width: Self.dividerGrabWidth) + // SwiftUI's own cursor modifier, and the only thing that works + // here. A hosted `NSView` with cursor rects and a hover + // callback pushing `NSCursor` both lose the cursor back to an + // arrow whenever SwiftUI updates the view — they are competing + // with the framework for ownership of it rather than asking. + // + // `columnResize` is the pointer for a vertical boundary that + // moves left and right, which is what this is. + .pointerStyle(.columnResize) + .gesture(resize) + // A clear shape is decorative as far as SwiftUI is concerned + // and is left out of the tree entirely, identifier and all. + // This is what makes it an element, so a driver can find the + // strip and drag it. + .accessibilityElement() + .accessibilityLabel("Resize sidebar") + .accessibilityIdentifier(AccessibilityID.paneDivider) + } + } + + /// Widen or narrow the sidebar by dragging the divider. + private var resize: some Gesture { + DragGesture(coordinateSpace: .global) + .onChanged { drag in + let start = dragStartWidth ?? sidebarWidth + dragStartWidth = start + sidebarWidth = min( + max(start + drag.translation.width, Self.sidebarWidths.lowerBound), + Self.sidebarWidths.upperBound + ) + } + .onEnded { _ in + dragStartWidth = nil + storedSidebarWidth = sidebarWidth + } + } + + /// What the menu commands act on. + /// + /// The list's own commands are handed a set by + /// `contextMenu(forSelectionType:)`, and this is the same thing for the menu + /// bar, which has no such argument to be given. + private var selectedIDs: Set { + selection.map { [$0] } ?? [] + } + + /// Open each named conversation in a window of its own. + private func openWindows( + for ids: Set, among all: [ConversationSummary] + ) { + for conversation in all where ids.contains(conversation.id) { + openWindow(id: ConversationWindow.sceneID, value: reference(to: conversation)) + } + } + + /// Put each named conversation's URI on the pasteboard, one per line. + private func copyLinks( + for ids: Set, among all: [ConversationSummary] + ) { + let links = + all + .filter { ids.contains($0.id) } + .map { reference(to: $0).uri } + .joined(separator: "\n") + + guard !links.isEmpty else { return } + + let pasteboard = DebugState.pasteboard + pasteboard.clearContents() + pasteboard.setString(links, forType: .string) + } + + /// Show `url`'s workspace in this window. + private func show(_ url: URL) { + selection = nil + // Canonicalized so a path chosen through the panel and the same path from + // the recents menu are one value, and reselecting the open workspace does + // not reload it. + workspacePath = url.canonicalized.path(percentEncoded: false) + } + + /// The conversation list as the window is currently showing it. + private struct Listing { + /// Everything the workspace holds. + let all: [ConversationSummary] + + /// What the filter leaves of it, in the order the sidebar shows them. + let matches: [ConversationSummary] + } + + /// The listing, once a workspace has been read. + /// + /// Derived once per render and handed to both the sidebar and the menu + /// actions. Filtering walks every conversation and this workspace holds a + /// thousand, so working it out twice is a thousand extra comparisons on every + /// keystroke. + private var listing: Listing? { + guard case .loaded(let all) = model.state else { return nil } + + return Listing( + all: all, + matches: ConversationOrder.pinnedFirst( + ConversationFilter.matches(all, query: query)) + ) + } + + /// The list, or why there is no list. + /// + /// The empty states replace the list rather than covering it, so no table + /// view exists to be updated while there is nothing to show. + @ViewBuilder + private func sidebar(_ listing: Listing?) -> some View { + switch model.state { + case .loading: + ProgressView() + .controlSize(.small) + .accessibilityLabel("Loading conversations") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier(AccessibilityID.Sidebar.loadingState) + + case .loaded: + if let listing { + VStack(spacing: 0) { + SearchField(text: $query) + // Measured rather than assumed: the field's height follows + // its font, and the window buttons are lined up against it. + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + searchFieldHeight = height + } + // Moves the window buttons down to the field's own centre. + // They sit 14 points down by default, which is the middle + // of a title bar this window does not have. + .background( + WindowButtons.placed( + leading: Self.windowButtonsLeading, + centredOn: Self.searchFieldPadding + searchFieldHeight / 2 + ) + ) + // Room for the window buttons, which the search field sits + // beside rather than below. + .padding(.leading, Self.windowButtonsWidth) + // The same on the other three sides, so the field sits in + // the corner of the window rather than against its edge. + .padding([.top, .trailing, .bottom], Self.searchFieldPadding) + + matchList(listing) + // A line above the first row only once the list has + // scrolled away from the top, which is what separates the + // search field from rows passing under it. At rest there is + // nothing to separate, and a line there reads as a border + // the design does not have. + // + // An overlay rather than a row in the stack, so its + // appearing does not shift the list down by a point. + .overlay(alignment: .top) { + if isListScrolled { + Rectangle() + .fill(Theme.rowSeparator.color) + .frame(height: 1) + } + } + } + .background(Theme.sidebarBackground.color) + // Takes the title bar's strip back from the system, which is what + // puts the search field level with the window buttons instead of + // under them. Safe because the search field is the topmost thing + // in the sidebar and it holds its own padding; nothing scrolls + // under the buttons. + .ignoresSafeArea(.container, edges: .top) + } + + case .unavailable(let title, let detail): + ContentUnavailableView(title, systemImage: "bubble.left", description: Text(detail)) + .accessibilityIdentifier(AccessibilityID.Sidebar.unavailableState) + } + } + + /// The conversations matching the filter, or a note that none do. + /// + /// The two replace each other rather than one covering the other, for the same + /// reason as the outer empty states: no table view should exist while there is + /// nothing for it to show. + @ViewBuilder + private func matchList(_ listing: Listing) -> some View { + if listing.matches.isEmpty { + ContentUnavailableView.search(text: query) + .accessibilityIdentifier(AccessibilityID.Sidebar.noMatchesState) + } else { + // `.equatable()` rather than left to SwiftUI's own judgement: this is + // the view whose body must be skipped while the divider is dragged, and + // the wrapper is what makes the comparison happen for certain. + ConversationList( + matches: listing.matches, + separatorless: ConversationOrder.rowsWithoutSeparator( + in: listing.matches, selecting: selection), + now: listingReadAt, + selectedID: selection, + selection: $selection, + reference: { reference(to: $0) }, + openWindows: { openWindows(for: $0, among: listing.all) }, + copyLinks: { copyLinks(for: $0, among: listing.all) }, + scrolledAwayFromTop: { isListScrolled = $0 } + ) + .equatable() + } + } + + private var title: String { + workspacePath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "JP" + } + + private func reference(to conversation: ConversationSummary) -> ConversationRef { + ConversationRef( + workspacePath: workspacePath ?? "", + conversationID: conversation.id, + title: conversation.title + ) + } + + /// How wide the sidebar is in a window that has never been resized. + private static let defaultSidebarWidth: Double = 280 + + /// How narrow and how wide the sidebar can be dragged. + /// + /// The lower bound is where a row's title stops being readable; the upper is + /// where the sidebar starts crowding the transcript. + private static let sidebarWidths: ClosedRange = 220...480 + + /// The line between the panes. + /// + /// One point, which is two pixels on a retina display and matches Bear. + private static let dividerWidth: CGFloat = 1 + + /// The narrowest the window can be. + /// + /// The narrowest sidebar, its divider, and enough left over for a line of + /// transcript to be worth reading. + private static let minimumWindowWidth: CGFloat = + CGFloat(sidebarWidths.lowerBound) + dividerWidth + 400 + + /// The shortest the window can be: a handful of conversation rows. + private static let minimumWindowHeight: CGFloat = 400 + + /// How much space surrounds the search field on the three sides the window + /// buttons do not occupy. + /// + /// Even padding and buttons level with the field cannot both be had from + /// layout alone: the buttons sit 14 points down, so an evenly padded field of + /// height `H` centres at `padding + H/2` and matching 14 forces the field + /// smaller the more padding it has. The padding is kept even and the buttons + /// are moved to meet it; see ``WindowButtons``. + private static let searchFieldPadding: CGFloat = 8 + + /// How wide a strip around the divider responds to a drag. + private static let dividerGrabWidth: CGFloat = 10 + + /// Where the first window button's frame is put, from the window's left edge. + /// + /// macOS puts it six points in, which reads as cramped against a window with + /// no title bar. Measured off Bear: the visible circle sits twenty points in, + /// and the frame is two points wider than the circle on each side. + private static let windowButtonsLeading: CGFloat = 18 + + /// How much of the sidebar's top-left corner the window buttons occupy. + /// + /// Three frames at ``WindowButtons/spacing``, from + /// ``windowButtonsLeading``, and then the gap before the search field starts. + private static let windowButtonsWidth: CGFloat = + windowButtonsLeading + 2 * WindowButtons.spacing + 16 + 6 + + /// What this window's events are attributed to. + private static let traceTarget = "JP.Workspace" + + private func load() async { + guard + let path = Self.chooseWorkspace( + stored: workspacePath, + mostRecent: recents.urls.first, + environment: ProcessInfo.processInfo.environment + ) + else { return } + + // Writing this back changes `task(id:)`, which cancels this run and starts + // another with the chosen path already stored. The second pass chooses the + // same path and writes nothing. + if workspacePath != path { + workspacePath = path + } + + // Recording the workspace here rather than only where it is chosen keeps + // a window restored at launch in the recents list too. + recents.note(URL(fileURLWithPath: path)) + await model.open(path) + } + + /// The workspace a window should show, in order of precedence. + /// + /// 1. `JP_WORKSPACE`, an instruction given at launch. + /// 2. The path this window stored, so a reopened window comes back where it + /// was and two windows can sit on two workspaces. + /// 3. The most recently opened workspace, which a new window overwhelmingly + /// wants and which saves choosing it again. + /// + /// The environment comes first because it is the only one of the three a + /// caller sets deliberately, per launch. Below the stored path it would be + /// read exactly once in a window's life and silently ignored on every later + /// launch, which makes `just run-app ` a no-op after the first run + /// and leaves a harness unable to point an instance anywhere. + /// `nonisolated` because it reads none of the view's state. A `View` is + /// main-actor isolated and its statics inherit that, which this does not need. + nonisolated static func chooseWorkspace( + stored: String?, + mostRecent: URL?, + environment: [String: String] + ) -> String? { + if let named = environment["JP_WORKSPACE"], !named.isEmpty { + return named + } + + if let stored, !stored.isEmpty { + return stored + } + + return mostRecent?.path(percentEncoded: false) + } + +} diff --git a/apps/macos/Tests/AccessibilityIDTests.swift b/apps/macos/Tests/AccessibilityIDTests.swift new file mode 100644 index 000000000..8912fe692 --- /dev/null +++ b/apps/macos/Tests/AccessibilityIDTests.swift @@ -0,0 +1,61 @@ +import Testing + +@testable import JP + +/// Pins the identifier strings themselves. +/// +/// An external driver looks elements up by these names, so they are a contract +/// with something outside this repository: changing one is a breaking change, +/// and these tests are what makes that visible in a diff. +@Suite("AccessibilityID") +struct AccessibilityIDTests { + @Test("names the sidebar's elements") + func namesTheSidebar() { + #expect(AccessibilityID.Sidebar.list == "sidebar.list") + #expect(AccessibilityID.Sidebar.filter == "sidebar.filter") + #expect(AccessibilityID.Sidebar.filterClear == "sidebar.filter.clear") + #expect(AccessibilityID.Sidebar.loadingState == "sidebar.state.loading") + #expect(AccessibilityID.Sidebar.noMatchesState == "sidebar.state.nomatches") + #expect(AccessibilityID.Sidebar.unavailableState == "sidebar.state.unavailable") + #expect(AccessibilityID.Sidebar.row("17251488000") == "sidebar.row.17251488000") + } + + @Test("names the transcript's elements") + func namesTheTranscript() { + #expect(AccessibilityID.Transcript.scroll == "transcript.scroll") + #expect(AccessibilityID.Transcript.loadingState == "transcript.state.loading") + #expect(AccessibilityID.Transcript.unavailableState == "transcript.state.unavailable") + #expect(AccessibilityID.Transcript.text == "transcript.text") + } + + /// The grab strip between the panes, which a driver reaches by dragging + /// because the sidebar's width cannot be written through the tree. + @Test("names the strip that resizes the sidebar") + func namesThePaneDivider() { + #expect(AccessibilityID.paneDivider == "window.divider") + } + + /// A driver that found a row before a title was generated has to still find + /// it afterwards, so the row's name comes from the conversation ID and + /// nothing else. + @Test("names a row the same before and after it is titled") + func survivesARetitle() { + let untitled = ConversationSummary( + id: "17251488000", + title: nil, + lastActivatedAt: "2026-08-01T09:00:00Z", + pinnedAt: nil, + eventsCount: 4 + ) + let titled = ConversationSummary( + id: "17251488000", + title: "Reading list", + lastActivatedAt: "2026-08-01T09:00:00Z", + pinnedAt: nil, + eventsCount: 4 + ) + + #expect( + AccessibilityID.Sidebar.row(untitled.id) == AccessibilityID.Sidebar.row(titled.id)) + } +} diff --git a/apps/macos/Tests/ClipboardPolicyTests.swift b/apps/macos/Tests/ClipboardPolicyTests.swift new file mode 100644 index 000000000..5fd405a9e --- /dev/null +++ b/apps/macos/Tests/ClipboardPolicyTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing + +/// The UI suite must never touch the *system* pasteboard. +/// +/// There is one of those and it belongs to whoever is at the keyboard. A test +/// that triggers a copy into it destroys what they had, and saving and +/// restoring around the test is not a fix: a pasteboard item can be a promise +/// its owner fulfils lazily, so a restore puts back a degraded copy and an +/// early exit puts back nothing at all. +/// +/// A *named* pasteboard has none of that problem, so the UI tests use one: a +/// debug build reads `JP_DEBUG_PASTEBOARD` and copies there instead (see +/// ``DebugState/pasteboard``), and `WorkspaceFixture.copiedText()` reads it +/// back. Copy Link is covered end to end without a clipboard being lost. +/// +/// What this forbids is therefore narrow and exact: the spellings that mean +/// "the one everybody shares". It is a source scan rather than a rule in a +/// document because a rule in a document is not enforced by anything. +@Suite("ClipboardPolicy") +struct ClipboardPolicyTests { + /// The spellings that reach the system pasteboard. + /// + /// `NSPasteboard(name: .general)` is the same object as + /// `NSPasteboard.general`, so naming it counts too. + static let forbidden = [ + "NSPasteboard.general", + "UIPasteboard.general", + "Name.general", + "name: .general", + ] + + @Test("no UI test reaches for the system pasteboard") + func uiTestsDoNotTouchThePasteboard() throws { + let sources = try Self.uiTestSources() + + // A scan over nothing passes for the wrong reason, and would keep + // passing if the directory were renamed. + #expect(sources.count >= 3, "expected to find the UI test sources to scan") + + for source in sources { + let text = try String(contentsOf: source, encoding: .utf8) + for symbol in Self.forbidden where text.contains(symbol) { + Issue.record( + """ + \(source.lastPathComponent) reaches the system pasteboard through \ + `\(symbol)`. Copy through the fixture's own pasteboard instead: the app \ + writes to the one `JP_DEBUG_PASTEBOARD` names, and \ + `WorkspaceFixture.copiedText()` reads it back. + """ + ) + } + } + } + + /// Every Swift file in `apps/macos/UITests`. + /// + /// Located from this file's compile-time path. The app is not sandboxed and + /// these tests are hosted by it, so the checkout is readable from here. + static func uiTestSources() throws -> [URL] { + // .../apps/macos/Tests/ClipboardPolicyTests.swift + let directory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("UITests") + + return try FileManager.default + .contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "swift" } + } +} diff --git a/apps/macos/Tests/ConversationDateTests.swift b/apps/macos/Tests/ConversationDateTests.swift new file mode 100644 index 000000000..94f2e248c --- /dev/null +++ b/apps/macos/Tests/ConversationDateTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing + +@testable import JP + +@Suite("ConversationDate") +struct ConversationDateTests { + /// UTC, so a fixed timestamp lands on the same calendar day wherever the test + /// runs. A machine in Auckland would otherwise read "12:30Z on 2 September" as + /// a different day from one in Los Angeles, and the same-day branch is the + /// whole point of half these tests. + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + guard let utc = TimeZone(identifier: "UTC") else { return calendar } + calendar.timeZone = utc + return calendar + }() + + /// Fixed, because a formatted date's word order and month name are the + /// locale's: "2 Sept" in one place, "Sep 2" in another. + private static let locale = Locale(identifier: "en_GB") + + /// The instant `text` names, or a failure naming what would not parse. + private func date(_ text: String) throws -> Date { + try #require(ConversationDate.parse(text), "\(text) did not parse") + } + + private func label(_ text: String, now: String) throws -> String { + ConversationDate.label( + for: try date(text), + now: try date(now), + calendar: Self.calendar, + locale: Self.locale + ) + } + + @Test("parses a whole-second timestamp") + func parsesWholeSeconds() throws { + #expect(try date("2024-09-02T12:30:00Z").timeIntervalSince1970 == 1_725_280_200) + } + + /// Any conversation JP created from a wall clock carries sub-second + /// precision, so this is the shape the app sees in practice. + @Test("parses a timestamp with fractional seconds") + func parsesFractionalSeconds() throws { + let parsed = try date("2024-09-02T12:30:00.500000Z") + + #expect(parsed.timeIntervalSince1970 == 1_725_280_200.5) + } + + @Test("reports a timestamp it cannot read") + func rejectsNonsense() { + #expect(ConversationDate.parse("") == nil) + #expect(ConversationDate.parse("yesterday") == nil) + #expect(ConversationDate.parse("2024-09-02") == nil) + } + + @Test("says how long ago a conversation active today was") + func minutesAgoToday() throws { + #expect( + try label("2026-08-03T09:39:00Z", now: "2026-08-03T10:00:00Z") == "21 minutes ago") + #expect( + try label("2026-08-03T09:59:00Z", now: "2026-08-03T10:00:00Z") == "1 minute ago") + #expect(try label("2026-08-03T08:00:00Z", now: "2026-08-03T10:00:00Z") == "2 hours ago") + #expect(try label("2026-08-03T09:00:00Z", now: "2026-08-03T10:00:00Z") == "1 hour ago") + } + + /// Under a minute has no useful number to show, and a clock adjustment can + /// put a stored timestamp slightly in the future. + @Test("says just now for anything under a minute, in either direction") + func justNow() throws { + #expect(try label("2026-08-03T09:59:30Z", now: "2026-08-03T10:00:00Z") == "just now") + #expect(try label("2026-08-03T10:00:30Z", now: "2026-08-03T10:00:00Z") == "just now") + } + + /// Yesterday is a different day even when it is only minutes ago, because a + /// row saying "40 minutes ago" for something dated yesterday reads as wrong. + @Test("dates a conversation from another day rather than timing it") + func earlierThisYear() throws { + #expect(try label("2026-08-02T23:40:00Z", now: "2026-08-03T00:20:00Z") == "2 Aug") + #expect(try label("2026-05-13T09:00:00Z", now: "2026-08-03T10:00:00Z") == "13 May") + } + + /// Without the year, a conversation from last July and one from this July + /// read identically. + /// + /// May, like the months the tests above use, is abbreviated the same way by + /// every ICU version. September is not — `Sep` and `Sept` are both current — + /// and pinning one of those would make this test an OS-update tripwire + /// rather than a check on the format. + @Test("adds the year for a conversation from an earlier one") + func earlierYear() throws { + #expect(try label("2024-05-13T09:00:00Z", now: "2026-08-03T10:00:00Z") == "13 May 2024") + } + + @Test("dates a conversation from its summary") + func labelsASummary() throws { + let conversation = ConversationSummary( + id: "17251488000", + title: "Reading list", + lastActivatedAt: "2026-05-13T09:00:00Z", + pinnedAt: nil, + eventsCount: 3 + ) + + #expect( + ConversationDate.activityLabel( + for: conversation, + now: try date("2026-08-03T10:00:00Z"), + calendar: Self.calendar, + locale: Self.locale + ) == "13 May" + ) + } + + /// A row shows no date rather than error text when the library reports + /// something this cannot read. + @Test("dates nothing when the summary's timestamp will not parse") + func labelsAnUnreadableSummary() throws { + let conversation = ConversationSummary( + id: "17251488000", + title: "Reading list", + lastActivatedAt: "not a timestamp", + pinnedAt: nil, + eventsCount: 3 + ) + + #expect( + ConversationDate.activityLabel( + for: conversation, + now: try date("2026-08-03T10:00:00Z"), + calendar: Self.calendar, + locale: Self.locale + ) == nil + ) + } +} diff --git a/apps/macos/Tests/ConversationFilterTests.swift b/apps/macos/Tests/ConversationFilterTests.swift new file mode 100644 index 000000000..e3d7ee335 --- /dev/null +++ b/apps/macos/Tests/ConversationFilterTests.swift @@ -0,0 +1,105 @@ +import Testing + +@testable import JP + +@Suite("ConversationFilter") +struct ConversationFilterTests { + /// Fixed IDs and titles, so an assertion names exactly what it expects. + private let conversations = [ + ConversationSummary( + id: "17855681129", + title: "Accessibility identifiers for driving", + lastActivatedAt: "2026-08-01T10:00:00Z", + pinnedAt: nil, + eventsCount: 116 + ), + ConversationSummary( + id: "17855681250", + title: "jpdrive: the accessibility driver", + lastActivatedAt: "2026-08-01T09:00:00Z", + pinnedAt: nil, + eventsCount: 124 + ), + ConversationSummary( + id: "17855299562", + title: "Café hours", + lastActivatedAt: "2026-08-01T08:00:00Z", + pinnedAt: nil, + eventsCount: 3 + ), + ConversationSummary( + id: "17801582617", + title: nil, + lastActivatedAt: "2026-07-01T08:00:00Z", + pinnedAt: nil, + eventsCount: 4 + ), + ] + + private func ids(_ query: String) -> [String] { + return ConversationFilter.matches(conversations, query: query).map(\.id) + } + + /// Clearing the box restores the list. An empty query meaning "match nothing" + /// would empty the sidebar the moment somebody deleted what they typed. + @Test("a blank query matches everything", arguments: ["", " ", "\n"]) + func blankMatchesEverything(query: String) { + #expect(ConversationFilter.matches(conversations, query: query).count == 4) + } + + @Test("matches anywhere in the title, not only at the start") + func matchesASubstring() { + #expect(ids("driver") == ["17855681250"]) + } + + @Test("ignores case") + func ignoresCase() { + #expect(ids("ACCESSIBILITY") == ["17855681129", "17855681250"]) + } + + /// `localizedStandardContains` folds diacritics, which is what a person typing + /// on a keyboard without the accent expects. + @Test("ignores diacritics") + func ignoresDiacritics() { + #expect(ids("cafe") == ["17855299562"]) + } + + /// An untitled conversation shows a placeholder, and a row a person can read + /// but not search for is a surprise. + @Test("finds untitled conversations by their placeholder") + func findsUntitled() { + #expect(ids("untitled") == ["17801582617"]) + } + + /// Surrounding whitespace comes free with pasting and typing, and no title has + /// a leading space to match anyway. + @Test("trims the query") + func trimsTheQuery() { + #expect(ids(" driver ") == ["17855681250"]) + } + + @Test("keeps the list in order") + func preservesOrder() { + #expect(ids("accessibility") == ["17855681129", "17855681250"]) + } + + @Test("matches nothing when nothing matches") + func matchesNothing() { + #expect(ids("zzz").isEmpty) + } + + /// IDs are timestamps. Searching them would let a query hit rows with no + /// visible reason, which reads as a bug rather than a feature. + @Test("does not match on the conversation ID") + func doesNotMatchIDs() { + #expect(ids("17855681129").isEmpty) + } + + /// The row and the filter have to agree about what an untitled conversation is + /// called, or one of them is lying. + @Test("the displayed title is the one searched") + func displayTitleIsShared() { + #expect(ConversationFilter.displayTitle(of: conversations[3]) == "Untitled") + #expect(ConversationFilter.displayTitle(of: conversations[2]) == "Café hours") + } +} diff --git a/apps/macos/Tests/ConversationOrderTests.swift b/apps/macos/Tests/ConversationOrderTests.swift new file mode 100644 index 000000000..9796867ae --- /dev/null +++ b/apps/macos/Tests/ConversationOrderTests.swift @@ -0,0 +1,110 @@ +import Testing + +@testable import JP + +@Suite("ConversationOrder") +struct ConversationOrderTests { + /// A conversation with a fixed ID, pinned or not. + /// + /// Nothing here reads the timestamps or the event count, so they are the same + /// for every one: what the ordering depends on is the pin and the position. + private func conversation(_ id: String, pinned: Bool = false) -> ConversationSummary { + ConversationSummary( + id: id, + title: "Conversation \(id)", + lastActivatedAt: "2026-08-01T10:00:00Z", + pinnedAt: pinned ? "2026-08-02T09:00:00Z" : nil, + eventsCount: 3 + ) + } + + private func ids(_ conversations: [ConversationSummary]) -> [String] { + ConversationOrder.pinnedFirst(conversations).map(\.id) + } + + @Test("lifts a pinned conversation above the unpinned ones") + func pinnedGoesFirst() { + let listing = [ + conversation("1"), + conversation("2"), + conversation("3", pinned: true), + ] + + #expect(ids(listing) == ["3", "1", "2"]) + } + + /// The library reports conversations most recently active first, and that + /// order has to survive inside each group: pinning is meant to lift one + /// conversation, not to reshuffle the rest. + @Test("keeps the given order inside each group") + func orderWithinGroupsIsKept() { + let listing = [ + conversation("1"), + conversation("2", pinned: true), + conversation("3"), + conversation("4", pinned: true), + ] + + #expect(ids(listing) == ["2", "4", "1", "3"]) + } + + @Test("leaves a list with no pins exactly as it was") + func noPinsChangesNothing() { + let listing = [conversation("1"), conversation("2"), conversation("3")] + + #expect(ids(listing) == ["1", "2", "3"]) + } + + @Test("leaves a list of nothing but pins exactly as it was") + func allPinnedChangesNothing() { + let listing = [ + conversation("1", pinned: true), + conversation("2", pinned: true), + ] + + #expect(ids(listing) == ["1", "2"]) + } + + @Test("orders an empty list") + func emptyList() { + #expect(ConversationOrder.pinnedFirst([]).isEmpty) + } + + private func bareRows(_ listing: [ConversationSummary], selecting: String?) -> Set { + ConversationOrder.rowsWithoutSeparator(in: listing, selecting: selecting) + } + + /// Both lines touching the selection go, not just the one under it: a + /// separator drawn above the selected row cuts across the top of its rounded + /// fill just as visibly as one below cuts the bottom. + @Test("drops the separator on the selected row and the one above it") + func dropsBothSeparatorsTouchingTheSelection() { + let listing = [conversation("1"), conversation("2"), conversation("3")] + + #expect(bareRows(listing, selecting: "2") == ["1", "2"]) + } + + /// There is no row above the first, so only its own line goes. + @Test("drops one separator when the first row is selected") + func firstRowHasNothingAboveIt() { + let listing = [conversation("1"), conversation("2")] + + #expect(bareRows(listing, selecting: "1") == ["1"]) + } + + @Test("draws every separator when nothing is selected") + func noSelectionDropsNothing() { + let listing = [conversation("1"), conversation("2")] + + #expect(bareRows(listing, selecting: nil).isEmpty) + } + + /// A filter can hide the selected conversation while it stays selected, and + /// the rows still on screen all keep their lines. + @Test("draws every separator when the selection is not in the list") + func selectionOutsideTheListDropsNothing() { + let listing = [conversation("1"), conversation("2")] + + #expect(bareRows(listing, selecting: "3").isEmpty) + } +} diff --git a/apps/macos/Tests/ConversationRefTests.swift b/apps/macos/Tests/ConversationRefTests.swift new file mode 100644 index 000000000..94fd505ea --- /dev/null +++ b/apps/macos/Tests/ConversationRefTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing + +@testable import JP + +@Suite("ConversationRef") +struct ConversationRefTests { + private let reference = ConversationRef( + workspacePath: "/tmp/my-workspace", + conversationID: "17251488000", + title: "Reading list" + ) + + /// The URI is the form JP itself uses to reference a conversation, so what + /// lands on the pasteboard is something a person can paste into a query. + /// + /// This is what the `Transferable` conformance exports, and so what both a + /// copy and a drag produce. The conformance itself is a single + /// `ProxyRepresentation` over this property; driving it through + /// `exported(as:)` needs an importable representation, which a reference has + /// no use for until something can accept a drop. + @Test("exports as a jp:// URI") + func exportsAsAURI() { + #expect(reference.uri == "jp://17251488000") + } + + /// A window restored from disk has no title, and still needs one to show. + @Test("falls back to the ID for a window title") + func fallsBackToTheID() { + let untitled = ConversationRef( + workspacePath: "/tmp/my-workspace", + conversationID: "17251488000" + ) + + #expect(untitled.displayTitle == "Conversation 17251488000") + } + + @Test("prefers the title for a window title") + func prefersTheTitle() { + #expect(reference.displayTitle == "Reading list") + } + + /// The system restores window values by encoding them, so a reference has to + /// survive a round trip with the workspace path intact — that path is what + /// lets a restored conversation window read its workspace without a + /// workspace window open. + @Test("survives the round trip the system restores windows through") + func survivesARoundTrip() throws { + let encoded = try JSONEncoder().encode(reference) + let decoded = try JSONDecoder().decode(ConversationRef.self, from: encoded) + + #expect(decoded == reference) + #expect(decoded.workspacePath == "/tmp/my-workspace") + } +} diff --git a/apps/macos/Tests/ConversationSummaryTests.swift b/apps/macos/Tests/ConversationSummaryTests.swift new file mode 100644 index 000000000..1bb9bb789 --- /dev/null +++ b/apps/macos/Tests/ConversationSummaryTests.swift @@ -0,0 +1,132 @@ +import Foundation +import Testing + +@testable import JP + +/// Decoding tests for the hand-maintained mirror of the Rust payload. +/// +/// The payloads here are copied verbatim from the assertions in +/// `crates/jp_ffi/src/lib_tests.rs`. When the Rust side changes shape, its tests +/// fail and so do these, which is the only link between the two definitions. +@Suite("ConversationSummary decoding") +struct ConversationSummaryTests { + private func decode(_ json: String) throws -> [ConversationSummary] { + try JSONDecoder().decode([ConversationSummary].self, from: Data(json.utf8)) + } + + @Test("decodes the payload the library emits") + func decodesLibraryPayload() throws { + let json = """ + [{"id":"17251488000","title":"Reading list",\ + "last_activated_at":"2024-09-02T12:30:00Z","events_count":0}] + """ + + let decoded = try decode(json) + + #expect( + decoded == [ + ConversationSummary( + id: "17251488000", + title: "Reading list", + lastActivatedAt: "2024-09-02T12:30:00Z", + pinnedAt: nil, + eventsCount: 0 + ) + ] + ) + } + + /// The key is present only for a pinned conversation, so its absence is what + /// says a conversation is not pinned. + @Test("decodes the payload a pinned conversation emits") + func decodesPinnedConversation() throws { + let json = """ + [{"id":"17251488000","title":"Reading list",\ + "last_activated_at":"2024-09-02T12:30:00Z",\ + "pinned_at":"2024-09-03T08:00:00Z","events_count":0}] + """ + + let decoded = try decode(json) + + #expect(decoded.first?.pinnedAt == "2024-09-03T08:00:00Z") + #expect(decoded.first?.isPinned == true) + } + + @Test("decodes a missing pin as not pinned") + func decodesMissingPin() throws { + let json = """ + [{"id":"17251488000","title":"Reading list",\ + "last_activated_at":"2024-09-02T12:30:00Z","events_count":0}] + """ + + let decoded = try decode(json) + + #expect(decoded.first?.pinnedAt == nil) + #expect(decoded.first?.isPinned == false) + } + + /// Any conversation JP created from a wall clock carries sub-second + /// precision, so this is the shape the app sees in practice. A `Date` field + /// using `JSONDecoder`'s `.iso8601` strategy would fail here while passing + /// the whole-second case above. + @Test("decodes a timestamp with fractional seconds") + func decodesFractionalSeconds() throws { + let json = """ + [{"id":"17251488000","title":"Reading list",\ + "last_activated_at":"2024-09-02T12:30:00.123456Z","events_count":0}] + """ + + let decoded = try decode(json) + + #expect(decoded.first?.lastActivatedAt == "2024-09-02T12:30:00.123456Z") + } + + /// A conversation keeps no title until one is generated or set. + @Test("decodes a missing title as nil") + func decodesMissingTitle() throws { + let json = """ + [{"id":"17251488000","last_activated_at":"2024-09-02T12:30:00Z","events_count":3}] + """ + + let decoded = try decode(json) + + #expect(decoded.first?.title == nil) + #expect(decoded.first?.eventsCount == 3) + } + + /// A field added on the Rust side must not break an app built against the + /// older shape, so unknown keys are ignored rather than rejected. + @Test("ignores fields it does not know") + func ignoresUnknownFields() throws { + let json = """ + [{"id":"17251488000","title":"Reading list",\ + "last_activated_at":"2024-09-02T12:30:00Z","events_count":0,\ + "some_field_from_a_newer_library":true}] + """ + + let decoded = try decode(json) + + #expect(decoded.count == 1) + } + + @Test("decodes an empty workspace") + func decodesEmptyList() throws { + #expect(try decode("[]").isEmpty) + } + + /// The list is keyed by `id` in SwiftUI, so two conversations must not + /// collide. + @Test("uses the conversation ID as its identity") + func identityIsTheConversationID() throws { + let json = """ + [{"id":"17251488000","title":"A","last_activated_at":"2024-09-02T12:30:00Z",\ + "events_count":0},\ + {"id":"17251488001","title":"B","last_activated_at":"2024-09-02T12:30:00Z",\ + "events_count":0}] + """ + + let decoded = try decode(json) + + #expect(decoded.map(\.id) == ["17251488000", "17251488001"]) + } +} diff --git a/apps/macos/Tests/ConversationTurnTests.swift b/apps/macos/Tests/ConversationTurnTests.swift new file mode 100644 index 000000000..467af967a --- /dev/null +++ b/apps/macos/Tests/ConversationTurnTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing + +@testable import JP + +/// Decoding tests for the hand-maintained mirror of the Rust projection. +/// +/// The payload here is copied verbatim from +/// `events_are_projected_as_turns_of_tagged_json` in +/// `crates/jp_ffi/src/lib_tests.rs`. When the Rust side changes shape, its test +/// fails and so does this one, which is the only link between the two +/// definitions. +@Suite("ConversationTurn decoding") +struct ConversationTurnDecodingTests { + private func decode(_ json: String) throws -> [ConversationTurn] { + try JSONDecoder().decode([ConversationTurn].self, from: Data(json.utf8)) + } + + @Test("decodes the payload the library emits") + func decodesLibraryPayload() throws { + let json = """ + [{"index":0,"events":[\ + {"type":"user_message","timestamp":"2024-09-01T10:00:01Z","author":"Jean",\ + "text":"What does this do?"},\ + {"type":"assistant_message","timestamp":"2024-09-01T10:00:03Z",\ + "text":"It reads conversations."}]}] + """ + + #expect( + try decode(json) == [ + ConversationTurn( + index: 0, + events: [ + .userMessage( + timestamp: "2024-09-01T10:00:01Z", + author: "Jean", + text: "What does this do?" + ), + .assistantMessage( + timestamp: "2024-09-01T10:00:03Z", + text: "It reads conversations." + ), + ] + ) + ] + ) + } + + /// The library numbers a turn by its place among all of them, so a turn it + /// had nothing to show for leaves a gap. Two turns in a row can be 0 and 2. + @Test("keeps the library's turn numbering, gaps and all") + func keepsTheLibrarysNumbering() throws { + let json = """ + [{"index":0,"events":[\ + {"type":"user_message","timestamp":"2024-09-01T10:00:00Z","text":"first"}]},\ + {"index":2,"events":[\ + {"type":"user_message","timestamp":"2024-09-01T10:00:02Z","text":"third"}]}] + """ + + #expect(try decode(json).map(\.index) == [0, 2]) + } + + /// A request authored before a display name was configured has no author, + /// and is still shown as the user's. + @Test("decodes a user message with no author") + func decodesUserMessageWithoutAuthor() throws { + let json = """ + [{"index":0,"events":[\ + {"type":"user_message","timestamp":"2024-09-01T10:00:00Z","text":"hi"}]}] + """ + + let turns = try decode(json) + + #expect( + turns.first?.events == [ + .userMessage(timestamp: "2024-09-01T10:00:00Z", author: nil, text: "hi") + ] + ) + #expect(turns.first?.events.first?.speaker == "You") + } + + /// A presentation added on the Rust side must not break an app built against + /// the older shape: the event it cannot draw is left out and the rest of the + /// turn still arrives. + @Test("skips a presentation it does not know, keeping the rest of the turn") + func skipsUnknownPresentation() throws { + let json = """ + [{"index":0,"events":[\ + {"type":"some_future_presentation","timestamp":"2024-09-01T10:00:00Z"},\ + {"type":"user_message","timestamp":"2024-09-01T10:00:01Z","text":"hi"}]}] + """ + + #expect( + try decode(json).first?.events == [ + .userMessage(timestamp: "2024-09-01T10:00:01Z", author: nil, text: "hi") + ] + ) + } + + /// Leniency stops at the `type` tag. A presentation this build *does* know, + /// arriving without the fields it promises, is a wire-format mistake and + /// fails rather than being quietly dropped. + @Test("fails on a known presentation missing its fields") + func failsOnMalformedKnownPresentation() { + let json = """ + [{"index":0,"events":[\ + {"type":"user_message","timestamp":"2024-09-01T10:00:00Z"}]}] + """ + + #expect(throws: (any Error).self) { + try decode(json) + } + } + + @Test("decodes an empty conversation") + func decodesEmptyList() throws { + #expect(try decode("[]").isEmpty) + } + + @Test("reads the timestamp and speaker of either presentation") + func readsTimestampAndSpeaker() throws { + let json = """ + [{"index":0,"events":[\ + {"type":"user_message","timestamp":"2024-09-01T10:00:00Z","author":"Jean","text":"hi"},\ + {"type":"assistant_message","timestamp":"2024-09-01T10:00:01Z","text":"hello"}]}] + """ + + let events = try #require(decode(json).first?.events) + + #expect(events.map(\.timestamp) == ["2024-09-01T10:00:00Z", "2024-09-01T10:00:01Z"]) + #expect(events.map(\.speaker) == ["Jean", "Assistant"]) + } +} diff --git a/apps/macos/Tests/DebugStateTests.swift b/apps/macos/Tests/DebugStateTests.swift new file mode 100644 index 000000000..a2d6925f6 --- /dev/null +++ b/apps/macos/Tests/DebugStateTests.swift @@ -0,0 +1,126 @@ +import Foundation +import Testing + +@testable import JP + +/// Nested in ``WorkspaceSuite`` because these write `JP_DEBUG_STATE_DIR`, which the +/// whole process shares. +extension WorkspaceSuite { + @MainActor + @Suite("DebugState") + struct DebugStateTests { + /// Run `body` with `JP_DEBUG_STATE_DIR` set to `directory`, and unset after. + /// + /// Unset rather than restored: the variable belongs to a harness driving the + /// app, so no test run has one to put back. + private func withStateDirectory(_ directory: URL?, _ body: () throws -> Void) throws { + if let directory { + setenv(DebugState.variable, directory.path(percentEncoded: false), 1) + } else { + unsetenv(DebugState.variable) + } + defer { unsetenv(DebugState.variable) } + + try body() + } + + /// The shipping configuration. A regression here would point the app's + /// recents at a file nothing reads, silently. + @Test("uses the system list when the variable is unset") + func usesTheSystemListWhenUnset() throws { + try withStateDirectory(nil) { + #expect(DebugState.directory == nil) + #expect(DebugState.defaultStore() is DocumentControllerRecents) + } + } + + /// The isolation the whole driving setup rests on: with the variable set, the + /// app must not read or write the list it shares with the system. + @Test("uses a file inside the state directory when the variable is set") + func usesAFileWhenSet() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + try withStateDirectory(root) { + let store = DebugState.defaultStore() + let file = try #require(store as? FileRecents) + #expect(file.path == root.appendingPathComponent("recents.json")) + } + } + + @Test("ignores an empty variable") + func ignoresAnEmptyVariable() throws { + setenv(DebugState.variable, "", 1) + defer { unsetenv(DebugState.variable) } + + #expect(DebugState.directory == nil) + #expect(DebugState.defaultStore() is DocumentControllerRecents) + } + + /// How a harness that launched the app through `open(1)` learns which + /// process it got. + @Test("records the process id") + func recordsTheProcessID() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + try withStateDirectory(root) { + DebugState.recordProcessID() + + let recorded = try String( + contentsOf: root.appendingPathComponent("pid"), + encoding: .utf8 + ) + #expect(recorded == "\(getpid())\n") + } + } + + /// A profiler subtracts this from every address it samples, and a recorder + /// that attached to an already-running app has no other way to learn it: the + /// kernel's image-load events only exist in a trace that was already + /// recording when dyld mapped the image. + /// + /// The format is a contract, not just a number. `Session::reported_slide` on + /// the Rust side parses it as an unsigned integer, so a sign or an `0x` + /// prefix would parse as nothing there and silently fall back to recovering + /// the slide from the trace — which is the failure this file exists to + /// avoid. + @Test("records the main image's ASLR slide") + func recordsTheImageSlide() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + try withStateDirectory(root) { + DebugState.recordProcessID() + + let recorded = try String( + contentsOf: root.appendingPathComponent("slide"), + encoding: .utf8 + ) + #expect(recorded == "\(_dyld_get_image_vmaddr_slide(0))\n") + + let text = recorded.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(UInt64(text) != nil) + } + } + + /// The tools name a directory that does not exist yet, then launch the app + /// into it. + @Test("creates the state directory to record into") + func createsTheStateDirectory() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let state = root.appendingPathComponent("state") + + try withStateDirectory(state) { + DebugState.recordProcessID() + + #expect( + FileManager.default.fileExists( + atPath: state.appendingPathComponent("pid").path(percentEncoded: false) + ) + ) + } + } + } +} diff --git a/apps/macos/Tests/MarkdownTests.swift b/apps/macos/Tests/MarkdownTests.swift new file mode 100644 index 000000000..079c73a9d --- /dev/null +++ b/apps/macos/Tests/MarkdownTests.swift @@ -0,0 +1,277 @@ +import AppKit +import Testing + +@testable import JP + +/// What markdown turns into, as text a TextKit view draws. +/// +/// The style is fixed rather than taken from ``Theme``, so every number and +/// colour asserted below is one this file states. +@Suite("Markdown") +struct MarkdownTests { + /// Distinct, obviously-not-real colours, so an assertion says which one was + /// applied rather than which appearance was resolved. + var style: MarkdownStyle { + MarkdownStyle( + body: .systemFont(ofSize: 14), + monospaced: .monospacedSystemFont(ofSize: 13, weight: .regular), + text: ThemeColor.srgb(0x11_1111), + secondary: ThemeColor.srgb(0x88_8888), + codeBackground: ThemeColor.srgb(0xEE_EEEE), + codeText: ThemeColor.srgb(0x22_2222), + link: ThemeColor.srgb(0x00_00FF), + indent: 20, + tableColumnWidth: 100, + blockSpacing: 10, + lineSpacing: 3, + eventSpacing: 18, + turnSpacing: 40 + ) + } + + private func render(_ source: String) -> NSAttributedString { + Markdown.attributed(source, style: style) + } + + private func paragraph(of rendered: NSAttributedString, at index: Int) -> NSParagraphStyle? + { + rendered.attribute(.paragraphStyle, at: index, effectiveRange: nil) as? NSParagraphStyle + } + + private func font(of rendered: NSAttributedString, at index: Int) -> NSFont? { + rendered.attribute(.font, at: index, effectiveRange: nil) as? NSFont + } + + /// Foundation's parser returns the blocks with nothing between them, so the + /// separators are the renderer's to put back. Without this a heading runs + /// into the paragraph under it. + @Test("separates blocks with newlines and leaves none trailing") + func separatesBlocks() { + let rendered = render( + """ + # Heading + + First paragraph. + + Second paragraph. + """) + + #expect(rendered.string == "Heading\nFirst paragraph.\nSecond paragraph.") + } + + @Test("draws a bullet before each item of an unordered list") + func drawsBullets() { + #expect(render("- first\n- second").string == "•\tfirst\n•\tsecond") + } + + @Test("numbers the items of an ordered list") + func numbersOrderedItems() { + #expect(render("1. first\n2. second").string == "1.\tfirst\n2.\tsecond") + } + + /// The number is the one written in the source, not the item's position: + /// a list starting at 3 is displayed starting at 3. + @Test("keeps the ordinal the source gave an item") + func keepsSourceOrdinals() { + #expect(render("3. third\n4. fourth").string == "3.\tthird\n4.\tfourth") + } + + /// The marker hangs in the indent its own level added and the text sits at + /// the indent, so a wrapped line lines up under the first rather than under + /// the bullet. + @Test("hangs a list marker outside the text it labels") + func hangsTheMarker() { + let rendered = render("- item") + let paragraph = paragraph(of: rendered, at: 0) + + #expect(paragraph?.firstLineHeadIndent == 0) + #expect(paragraph?.headIndent == 20) + #expect(paragraph?.tabStops.first?.location == 20) + } + + @Test("indents a nested list one level further") + func indentsNestedLists() { + let rendered = render("- outer\n - inner") + let inner = rendered.string.distance( + from: rendered.string.startIndex, + to: rendered.string.range(of: "inner")?.lowerBound ?? rendered.string.startIndex + ) + + #expect(paragraph(of: rendered, at: inner)?.headIndent == 40) + } + + @Test("draws a heading larger than body text, and in bold") + func drawsHeadings() { + let first = font(of: render("# One"), at: 0) + let third = font(of: render("### Three"), at: 0) + + #expect(first?.pointSize == 22) + #expect(third?.pointSize == 16) + #expect(first?.fontDescriptor.symbolicTraits.contains(.bold) == true) + } + + /// A heading past the third is the body size in bold: another distinct size + /// would be a difference nobody can see. + @Test("draws a deep heading at body size") + func drawsDeepHeadingsAtBodySize() { + #expect(font(of: render("##### Five"), at: 0)?.pointSize == 14) + } + + @Test("applies emphasis to the emphasized run alone") + func appliesEmphasis() { + let rendered = render("plain **bold** plain") + let bold = 6 + + #expect(rendered.string == "plain bold plain") + #expect( + font(of: rendered, at: bold)?.fontDescriptor.symbolicTraits.contains(.bold) == true) + #expect( + font(of: rendered, at: 0)?.fontDescriptor.symbolicTraits.contains(.bold) == false) + } + + @Test("sets an inline code span in the monospaced font, on the code background") + func stylesInlineCode() { + let rendered = render("run `jp query` now") + let code = 4 + + #expect(rendered.string == "run jp query now") + #expect(font(of: rendered, at: code) == style.monospaced) + #expect( + rendered.attribute(.backgroundColor, at: code, effectiveRange: nil) as? NSColor + == style.codeBackground + ) + // The prose either side keeps the body font and no background. + #expect(font(of: rendered, at: 0) == style.body) + #expect(rendered.attribute(.backgroundColor, at: 0, effectiveRange: nil) == nil) + } + + /// A fenced block keeps its own newlines, and loses the one before the + /// closing fence — which would otherwise draw an empty last line inside the + /// block's background. + @Test("keeps a code block's lines and drops the fence's trailing newline") + func stylesCodeBlocks() { + let rendered = render("```swift\nlet x = 1\nprint(x)\n```") + + #expect(rendered.string == "let x = 1\nprint(x)") + #expect(font(of: rendered, at: 0) == style.monospaced) + #expect( + rendered.attribute(.backgroundColor, at: rendered.length - 1, effectiveRange: nil) + as? NSColor == style.codeBackground + ) + } + + @Test("carries a link's destination, colour and underline") + func stylesLinks() { + let rendered = render("see [the docs](https://example.com/x) for more") + let link = 4 + + #expect(rendered.string == "see the docs for more") + #expect( + rendered.attribute(.link, at: link, effectiveRange: nil) as? URL + == URL(string: "https://example.com/x") + ) + #expect( + rendered.attribute(.foregroundColor, at: link, effectiveRange: nil) as? NSColor + == style.link + ) + } + + @Test("indents a block quote and dims it") + func stylesBlockQuotes() { + let rendered = render("> quoted") + + #expect(rendered.string == "quoted") + #expect(paragraph(of: rendered, at: 0)?.headIndent == 20) + #expect( + rendered.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor + == style.secondary + ) + } + + /// A soft break inside a paragraph is a space, per CommonMark, and the + /// terminal renderer reflows the same way. A hard break is a new line. + @Test("reflows a soft break and honours a hard one") + func handlesLineBreaks() { + #expect(render("one\ntwo").string == "one two") + #expect(render("one \ntwo").string == "one\ntwo") + } + + /// A row is one line of tab-separated cells, not one line per cell. The tab is + /// what carries a cell to its column, so its presence is the assertion. + @Test("lays a table row out as one line of tab-separated cells") + func laysOutTableRows() { + let rendered = render( + """ + | container | samples | + | --- | --- | + | VStack | 2442 | + """) + + #expect(rendered.string == "container\tsamples\nVStack\t2442") + } + + /// One stop per column boundary, so the second cell starts where the second + /// column does. The first needs none: it starts at the paragraph's own edge. + @Test("puts a tab stop at each column boundary") + func stopsAtColumnBoundaries() { + let rendered = render("| a | b | c |\n| --- | --- | --- |\n| 1 | 2 | 3 |") + + #expect(paragraph(of: rendered, at: 0)?.tabStops.map(\.location) == [100, 200]) + } + + /// The one piece of table styling the source states outright. `---:` in the + /// separator row right-aligns a column, and Foundation reports it, so a column + /// of numbers lines up on its digits. + @Test("takes each column's alignment from the source") + func alignsColumnsAsWritten() { + let rendered = render("| a | b | c |\n| :-- | :-: | --: |\n| 1 | 2 | 3 |") + let stops = paragraph(of: rendered, at: 0)?.tabStops + + // The first column has no stop, so these are columns two and three. + #expect(stops?.map(\.alignment) == [.center, .right]) + } + + @Test("draws a table's header row in bold and its body rows plain") + func boldsTheHeaderRow() { + let rendered = render("| head |\n| --- |\n| body |") + let body = rendered.string.distance( + from: rendered.string.startIndex, + to: rendered.string.range(of: "body")?.lowerBound ?? rendered.string.startIndex + ) + + #expect( + font(of: rendered, at: 0)?.fontDescriptor.symbolicTraits.contains(.bold) == true) + #expect( + font(of: rendered, at: body)?.fontDescriptor.symbolicTraits.contains(.bold) == false + ) + } + + /// Inline styling inside a cell survives, which is what says the cells go + /// through the same run walk as prose rather than being flattened to plain + /// text on the way into a row. + @Test("keeps inline styling inside a cell") + func stylesInsideCells() { + let rendered = render("| a |\n| --- |\n| `code` |") + let cell = rendered.string.distance( + from: rendered.string.startIndex, + to: rendered.string.range(of: "code")?.lowerBound ?? rendered.string.startIndex + ) + + #expect(font(of: rendered, at: cell) == style.monospaced) + } + + /// A table is one block, so the rows sit against each other and the spacing + /// belongs to whatever follows. + @Test("separates a table from the prose around it") + func separatesTablesFromProse() { + let rendered = render("before\n\n| a |\n| --- |\n| 1 |\n\nafter") + + #expect(rendered.string == "before\na\n1\nafter") + } + + @Test("renders text that is not markdown as itself") + func rendersPlainText() { + #expect(render("just a sentence.").string == "just a sentence.") + #expect(render("").string == "") + } +} diff --git a/apps/macos/Tests/RecentWorkspacesTests.swift b/apps/macos/Tests/RecentWorkspacesTests.swift new file mode 100644 index 000000000..fa1dcbf89 --- /dev/null +++ b/apps/macos/Tests/RecentWorkspacesTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing + +@testable import JP + +/// Outside ``WorkspaceSuite``, because each test owns the file its list is kept in +/// and so touches no state shared with the rest of the process. Backing these by +/// `NSDocumentController` would mean every run clearing the developer's own +/// `File ▸ Open Recent`. +@MainActor +@Suite("RecentWorkspaces") +struct RecentWorkspacesTests { + /// A list backed by a file inside `root`. + private func makeRecents(in root: URL) -> RecentWorkspaces { + RecentWorkspaces(store: FileRecents(path: root.appendingPathComponent("recents.json"))) + } + + @Test("starts empty") + func startsEmpty() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + #expect(makeRecents(in: root).urls.isEmpty) + } + + @Test("records an opened workspace") + func recordsAnOpenedWorkspace() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let workspace = URL(fileURLWithPath: try makeWorkspace(in: root)).canonicalized + + let recents = makeRecents(in: root) + recents.note(workspace) + + #expect(recents.urls == [workspace]) + } + + /// The temporary directory lives under a symlink, so a path recorded as given + /// would never match the canonical one a window is keyed by. + @Test("records a workspace under its canonical path") + func canonicalizesTheRecordedPath() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let workspace = URL(fileURLWithPath: try makeWorkspace(in: root)) + + let recents = makeRecents(in: root) + recents.note(workspace) + + #expect(recents.urls == [workspace.canonicalized]) + } + + /// A path read back out of the list is spelled the way a window is keyed by it. + /// `URL(fileURLWithPath:)` marks an existing directory as one, so without + /// normalizing, every entry carries a trailing slash the window keys do not. + @Test("records a workspace without a trailing slash") + func stripsATrailingSlash() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let workspace = URL(fileURLWithPath: try makeWorkspace(in: root)) + + let recents = makeRecents(in: root) + recents.note(workspace) + + let recorded = try #require(recents.urls.first) + #expect(!recorded.path(percentEncoded: false).hasSuffix("/")) + } + + /// Reopening moves a workspace back to the front, which is what makes the + /// menu ordering useful. + @Test("puts the most recently opened workspace first") + func putsTheMostRecentFirst() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let first = URL(fileURLWithPath: try makeWorkspace(in: root, named: "first")) + .canonicalized + let second = URL(fileURLWithPath: try makeWorkspace(in: root, named: "second")) + .canonicalized + + let recents = makeRecents(in: root) + recents.note(first) + recents.note(second) + recents.note(first) + + #expect(recents.urls == [first, second]) + } + + /// A workspace can be deleted between launches, and offering to open one that + /// is gone produces an error the user cannot act on. + @Test("drops a workspace that no longer exists") + func dropsAMissingWorkspace() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let kept = URL(fileURLWithPath: try makeWorkspace(in: root, named: "kept")) + .canonicalized + let removed = URL(fileURLWithPath: try makeWorkspace(in: root, named: "removed")) + .canonicalized + + let recents = makeRecents(in: root) + recents.note(kept) + recents.note(removed) + #expect(recents.urls == [removed, kept]) + + try FileManager.default.removeItem(at: removed) + + // A fresh instance reads the stored list, as a relaunch would. + #expect(makeRecents(in: root).urls == [kept]) + } + + @Test("clears the list") + func clearsTheList() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let workspace = URL(fileURLWithPath: try makeWorkspace(in: root)).canonicalized + + let recents = makeRecents(in: root) + recents.note(workspace) + recents.clear() + + #expect(recents.urls.isEmpty) + #expect(makeRecents(in: root).urls.isEmpty) + } +} diff --git a/apps/macos/Tests/RecentsStoreTests.swift b/apps/macos/Tests/RecentsStoreTests.swift new file mode 100644 index 000000000..1c99831fb --- /dev/null +++ b/apps/macos/Tests/RecentsStoreTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing + +@testable import JP + +/// ``FileRecents`` on its own, without the canonicalizing and pruning +/// ``RecentWorkspaces`` layers on top. Paths here need not exist on disk. +@MainActor +@Suite("FileRecents") +struct FileRecentsTests { + /// A store at `recents.json` inside a directory of its own. + private func makeStore(in root: URL) -> FileRecents { + FileRecents(path: root.appendingPathComponent("recents.json")) + } + + @Test("reads an absent file as an empty list") + func readsAnAbsentFileAsEmpty() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + #expect(makeStore(in: root).urls().isEmpty) + } + + @Test("reads back what it wrote, most recent first") + func roundTripsInOrder() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + let store = makeStore(in: root) + store.note(URL(fileURLWithPath: "/one")) + store.note(URL(fileURLWithPath: "/two")) + + #expect(store.urls().map { $0.path(percentEncoded: false) } == ["/two", "/one"]) + } + + @Test("moves a repeated path to the front rather than duplicating it") + func movesARepeatedPathToTheFront() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + let store = makeStore(in: root) + store.note(URL(fileURLWithPath: "/one")) + store.note(URL(fileURLWithPath: "/two")) + store.note(URL(fileURLWithPath: "/one")) + + #expect(store.urls().map { $0.path(percentEncoded: false) } == ["/one", "/two"]) + } + + @Test("keeps only the most recent paths") + func keepsOnlyTheMostRecentPaths() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + let store = makeStore(in: root) + for index in 0...FileRecents.capacity { + store.note(URL(fileURLWithPath: "/workspace-\(index)")) + } + + let paths = store.urls().map { $0.path(percentEncoded: false) } + #expect(paths.count == FileRecents.capacity) + #expect(paths.first == "/workspace-\(FileRecents.capacity)") + #expect(paths.last == "/workspace-1") + } + + /// The file is a harness's to write, so it can arrive malformed. An empty list + /// costs the menu its entries; refusing to produce one would cost the window + /// its workspace. + @Test("reads a malformed file as an empty list") + func readsAMalformedFileAsEmpty() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let store = makeStore(in: root) + try "not json".write(to: store.path, atomically: true, encoding: .utf8) + + #expect(store.urls().isEmpty) + } + + @Test("clears the file") + func clearsTheFile() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + let store = makeStore(in: root) + store.note(URL(fileURLWithPath: "/one")) + store.clear() + + #expect(store.urls().isEmpty) + } + + /// The tools write the file before the app has ever run, into a directory that + /// may not exist yet. + @Test("creates the directory it writes into") + func createsTheDirectory() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let store = FileRecents(path: root.appendingPathComponent("state/recents.json")) + + store.note(URL(fileURLWithPath: "/one")) + + #expect(store.urls().map { $0.path(percentEncoded: false) } == ["/one"]) + } +} diff --git a/apps/macos/Tests/TestSandbox.swift b/apps/macos/Tests/TestSandbox.swift new file mode 100644 index 000000000..bf2b64913 --- /dev/null +++ b/apps/macos/Tests/TestSandbox.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing + +/// The suite every test that touches process-wide state belongs to. +/// +/// Opening a workspace reads `JP_USER_DATA_DIR` from the environment, and the +/// recent-workspaces list is system state shared by the whole process. Neither +/// can be made per-test, so the tests that use them are serialized instead — and +/// serialized *together*, which is why they are nested here: `.serialized` orders +/// the tests within a suite, and sibling suites still run alongside each other. +/// +/// Nesting from another file is what the `extension WorkspaceSuite` declarations +/// in the sibling test files are doing. +/// +/// Tests that touch none of this — decoding, ordering, presentation — stay +/// outside and run in parallel. +@Suite("Workspace", .serialized) +struct WorkspaceSuite {} + +/// Create a disposable directory tree for one test, and point user-local storage +/// inside it. +/// +/// Paired with ``removeSandbox(_:)`` through `defer` rather than owned by an +/// object with a `deinit`: ARC may release such an object right after its last +/// mention, which can be before the awaited work that uses the directory runs, +/// deleting the fixture out from under the test. +/// +/// Only safe to call from inside ``WorkspaceSuite``, because it writes the +/// environment the whole process shares. Use ``makeTemporaryDirectory()`` for a +/// directory without that constraint. +func makeSandbox() throws -> URL { + let root = try makeTemporaryDirectory() + + // Keep the user-local conversation store, which opening a workspace creates, + // inside this test's own directory rather than the real user data directory. + setenv("JP_USER_DATA_DIR", root.appendingPathComponent("user-data").path, 1) + unsetenv("XDG_DATA_HOME") + + return root +} + +/// Create a disposable directory for one test. +/// +/// Writes no environment and touches nothing outside itself, so it is safe from +/// any suite. Paired with ``removeSandbox(_:)``. +func makeTemporaryDirectory() throws -> URL { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("jp-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root +} + +func removeSandbox(_ root: URL) { + try? FileManager.default.removeItem(at: root) +} + +/// Create a workspace root with an empty store, and return its path. +/// +/// The workspace ID is written rather than left for JP to mint, because JP +/// derives one from the current millisecond: two workspaces created in the same +/// millisecond would share an ID, and with it the user-local store keyed by it. +func makeWorkspace(in root: URL, named name: String = "my-workspace") throws -> String { + let workspace = root.appendingPathComponent(name) + let store = workspace.appendingPathComponent(".jp") + try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true) + + // `Id::load` reads the last line, and rejects anything that is not five + // characters of `[0-9a-z]`. + let preamble = "DO NOT EDIT THIS FILE! IT IS AUTO-GENERATED BY JP." + try "\(preamble)\n\(makeWorkspaceID())\n" + .write(to: store.appendingPathComponent(".id"), atomically: true, encoding: .utf8) + + return workspace.path +} + +/// Create a directory with no workspace in it, and return its path. +func makeBareDirectory(in root: URL, named name: String) throws -> String { + let directory = root.appendingPathComponent(name) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.path +} + +/// A workspace ID in the shape JP accepts: five characters of `[0-9a-z]`. +private func makeWorkspaceID() -> String { + let alphabet = Array("0123456789abcdefghijklmnopqrstuvwxyz") + return String((0..<5).map { _ in alphabet.randomElement() ?? "0" }) +} diff --git a/apps/macos/Tests/ThemeTests.swift b/apps/macos/Tests/ThemeTests.swift new file mode 100644 index 000000000..693fefdf2 --- /dev/null +++ b/apps/macos/Tests/ThemeTests.swift @@ -0,0 +1,124 @@ +import AppKit +import Testing + +@testable import JP + +/// Tests for the palette and the mechanism that resolves it. +/// +/// Not a pin of every hex value: those are declared once in `Theme.swift` and +/// re-typing them here would only assert that copy-paste works. What is worth +/// holding is the wiring — that a colour resolves to its light half under a light +/// appearance and its dark half under a dark one, and that a value survives the +/// trip through `NSColor` unchanged. +@Suite("Theme") +struct ThemeTests { + /// Every colour the palette declares, so a test can hold all of them to the + /// same rule at once. + private static let palette: [(name: String, color: ThemeColor)] = [ + ("sidebarBackground", Theme.sidebarBackground), + ("selectedRowBackground", Theme.selectedRowBackground), + ("paneDivider", Theme.paneDivider), + ("rowSeparator", Theme.rowSeparator), + ("searchFieldBackground", Theme.searchFieldBackground), + ("editorBackground", Theme.editorBackground), + ("bodyText", Theme.bodyText), + ("secondaryText", Theme.secondaryText), + ("accent", Theme.accent), + ("inlineCodeBackground", Theme.inlineCodeBackground), + ("inlineCodeText", Theme.inlineCodeText), + ("tagBackground", Theme.tagBackground), + ("tagText", Theme.tagText), + ] + + /// The hex `color` resolves to under `appearance`, read back off the drawn + /// colour rather than off the declaration. + /// + /// A dynamic `NSColor` reports nothing about its components until it is + /// resolved against an appearance, which is what `usingColorSpace` after + /// `performAsCurrentDrawingAppearance` does here. + private func drawn(_ color: ThemeColor, under appearance: NSAppearance) -> UInt32? { + var resolved: NSColor? + appearance.performAsCurrentDrawingAppearance { + resolved = color.nsColor.usingColorSpace(.sRGB) + } + + guard let resolved else { return nil } + + let component = { (value: CGFloat) in UInt32((value * 255).rounded()) } + return component(resolved.redComponent) << 16 + | component(resolved.greenComponent) << 8 + | component(resolved.blueComponent) + } + + @Test("draws its light half under a light appearance") + func resolvesLight() throws { + let aqua = try #require(NSAppearance(named: .aqua)) + + for entry in Self.palette { + #expect( + drawn(entry.color, under: aqua) == entry.color.light, + "\(entry.name) drew the wrong colour in light appearance" + ) + } + } + + @Test("draws its dark half under a dark appearance") + func resolvesDark() throws { + let darkAqua = try #require(NSAppearance(named: .darkAqua)) + + for entry in Self.palette { + #expect( + drawn(entry.color, under: darkAqua) == entry.color.dark, + "\(entry.name) drew the wrong colour in dark appearance" + ) + } + } + + /// One line between the panes, dozens between the rows: at the same weight + /// the list reads as a grid, so the two are deliberately different. + @Test("separates rows more lightly than it separates panes") + func rowsAreSeparatedMoreLightly() { + #expect(Theme.rowSeparator.light > Theme.paneDivider.light) + } + + /// The two halves of every colour differ. A pair that matched would be a + /// half-finished copy-paste, and it looks like a working app right up until + /// somebody switches appearance and finds white text on white. + @Test("gives every colour two distinct halves") + func halvesDiffer() { + for entry in Self.palette { + #expect( + entry.color.light != entry.color.dark, + "\(entry.name) is the same colour in both appearances" + ) + } + } + + /// The accessibility appearances are variants of the two base ones and have + /// names of their own, so matching by name alone would send a high-contrast + /// dark window down the light path. + @Test("treats the high-contrast dark appearance as dark") + func highContrastDarkIsDark() throws { + let variant = try #require(NSAppearance(named: .accessibilityHighContrastDarkAqua)) + + #expect(variant.isDark) + } + + @Test("treats the high-contrast light appearance as light") + func highContrastLightIsLight() throws { + let variant = try #require(NSAppearance(named: .accessibilityHighContrastAqua)) + + #expect(variant.isDark == false) + } + + /// The channels are unpacked in the right order, which a grey would hide. + @Test("unpacks a hex value into its channels") + func unpacksChannels() throws { + let color = try #require(ThemeColor.srgb(0x11_22_33).usingColorSpace(.sRGB)) + + #expect((color.redComponent * 255).rounded() == 0x11) + #expect((color.greenComponent * 255).rounded() == 0x22) + #expect((color.blueComponent * 255).rounded() == 0x33) + #expect(color.alphaComponent == 1) + } +} diff --git a/apps/macos/Tests/TraceTests.swift b/apps/macos/Tests/TraceTests.swift new file mode 100644 index 000000000..e66c69151 --- /dev/null +++ b/apps/macos/Tests/TraceTests.swift @@ -0,0 +1,173 @@ +import Foundation +import Testing + +@testable import JP + +/// One line exactly as the app writes it. +/// +/// Pinned here and in `.config/jp/tools/src/debug_app/trace_tests.rs`, character +/// for character. Nothing else checks that the writer and the reader agree on +/// the format: if one of these two strings is edited alone, the other test is +/// what says so. +private let appLine = """ + {"timestamp":"2026-08-02T11:04:12.418293Z","level":"INFO","target":"JP.Transcript",\ + "fields":{"message":"transcript.render","duration_ms":84.219,"event_count":847,\ + "footprint_mb":412},"spans":[{"name":"conversation.select"}]} + """ + +@Suite("Trace") +struct TraceTests { + @Test("writes the line the tooling parses") + func writesThePinnedLine() throws { + let line = try #require( + Trace.line( + timestamp: "2026-08-02T11:04:12.418293Z", + level: .info, + target: "JP.Transcript", + message: "transcript.render", + fields: [ + ("duration_ms", 84.219), + ("event_count", 847), + ("footprint_mb", 412), + ], + spans: ["conversation.select"] + ) + ) + + #expect(line == appLine) + } + + /// The parser treats `spans` as optional, and most events are not nested + /// inside anything. + @Test("leaves the span stack out when there is none") + func omitsAnEmptySpanStack() throws { + let line = try #require( + Trace.line( + timestamp: "2026-08-02T11:04:10.000000Z", + level: .info, + target: "JP.Trace", + message: "trace.origin", + fields: [("timebase_numer", 125), ("timebase_denom", 3)], + spans: [] + ) + ) + + #expect( + line == """ + {"timestamp":"2026-08-02T11:04:10.000000Z","level":"INFO","target":"JP.Trace",\ + "fields":{"message":"trace.origin","timebase_numer":125,"timebase_denom":3}} + """ + ) + } + + /// RFC 3339, UTC, fractional seconds. A timestamp in local time or without + /// the fraction still parses, and lands the event in the wrong place on a + /// timeline drawn beside `jp`'s. + @Test("formats timestamps as UTC to the microsecond") + func formatsTimestamps() { + #expect( + Trace.timestamp(Date(timeIntervalSince1970: 1_785_668_652.418293)) + == "2026-08-02T11:04:12.418293Z" + ) + #expect( + Trace.timestamp(Date(timeIntervalSince1970: 0)) == "1970-01-01T00:00:00.000000Z") + } + + @Test("reports the process footprint") + func reportsTheFootprint() throws { + let footprint = try #require(Trace.footprintMB()) + + // A live process occupies something, and a footprint of hundreds of + // gigabytes would mean the struct was read as the wrong shape. + #expect(footprint > 0) + #expect(footprint < 100_000) + } + + @Test("converts mach ticks to milliseconds") + func convertsMachTicks() { + let timebase = MachTimebase.current + // Exactly one second's worth of ticks, whatever this machine counts in. + let ticks = + UInt64(1_000_000_000) * UInt64(timebase.denominator) + / UInt64(timebase.numerator) + + #expect(abs(Trace.milliseconds(ticks) - 1000) < 0.01) + } +} + +/// Nested in ``WorkspaceSuite`` because these read `JP_DEBUG_STATE_DIR`, which +/// the whole process shares. +extension WorkspaceSuite { + @Suite("TraceWriter") + struct TraceWriterTests { + /// The shipping configuration, and the one thing this must never get + /// wrong: an installed app writing a trace file into someone's disk + /// would be a defect, not a feature. + @Test("creates nothing without a state directory") + func createsNothingWithoutADirectory() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + #expect(TraceWriter(directory: nil, fileName: Trace.fileName) == nil) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + } + + /// The process-wide sink, resolved from the environment the test host + /// runs under. Serialized with the tests that set that variable, so it + /// is unset here. + @Test("records nothing when the app was launched as it ships") + func recordsNothingWhenLaunchedNormally() { + #expect(ProcessInfo.processInfo.environment[DebugState.variable] == nil) + #expect(Trace.isRecording == false) + #expect(Trace.url == nil) + + // Reaches every sink there is. Nothing to assert but that it neither + // crashes nor has a file to write to. + Trace.event("test.event") + Trace.interval("test.interval").end() + } + + @Test("appends one line per event") + func appendsOneLinePerEvent() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + let writer = try #require(TraceWriter(directory: root, fileName: Trace.fileName)) + writer.append("first") + writer.append("second") + + #expect(writer.url == root.appendingPathComponent("trace.jsonl")) + #expect(try String(contentsOf: writer.url, encoding: .utf8) == "first\nsecond\n") + } + + /// The directory a harness names does not exist yet when it launches the + /// app into it. + @Test("creates the directory it was pointed at") + func createsTheDirectory() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + let state = root.appendingPathComponent("state") + + let writer = try #require(TraceWriter(directory: state, fileName: Trace.fileName)) + writer.append("line") + + #expect(try String(contentsOf: writer.url, encoding: .utf8) == "line\n") + } + + /// A relaunch truncates the file, and a second writer on the same path + /// must not overwrite what the first one wrote. + @Test("appends to a file that already has lines in it") + func appendsToAnExistingFile() throws { + let root = try makeTemporaryDirectory() + defer { removeSandbox(root) } + + let first = try #require(TraceWriter(directory: root, fileName: Trace.fileName)) + first.append("first") + + let second = try #require(TraceWriter(directory: root, fileName: Trace.fileName)) + second.append("second") + + #expect(try String(contentsOf: second.url, encoding: .utf8) == "first\nsecond\n") + } + } +} diff --git a/apps/macos/Tests/TranscriptTextViewTests.swift b/apps/macos/Tests/TranscriptTextViewTests.swift new file mode 100644 index 000000000..2ca1b5711 --- /dev/null +++ b/apps/macos/Tests/TranscriptTextViewTests.swift @@ -0,0 +1,77 @@ +import AppKit +import Testing + +@testable import JP + +/// How the transcript's text view is set up. +/// +/// Configuration rather than behaviour, and worth pinning because each of these +/// is a line that looks like tidying and is not: the transcript reads correctly +/// with any of them wrong, and then misbehaves in a way that looks like a layout +/// bug. +@Suite("TranscriptTextView") +@MainActor +struct TranscriptTextViewTests { + private func configured() -> NSTextView { + let textView = NSTextView(usingTextLayoutManager: false) + TranscriptTextView.configure(textView) + return textView + } + + /// The pointing hand over a link is this dictionary and nothing else. The + /// default carries a colour and an underline alongside it, which would draw + /// over the ones the document already has — so the cursor is kept and the rest + /// dropped, rather than the whole dictionary emptied. + @Test("keeps the pointing hand over links without AppKit's link styling") + func stylesLinkCursorOnly() { + let attributes = configured().linkTextAttributes ?? [:] + + #expect(attributes[.cursor] as? NSCursor == NSCursor.pointingHand) + #expect(attributes[.foregroundColor] == nil) + #expect(attributes[.underlineStyle] == nil) + } + + /// Readable and selectable, which is what makes ⌘C and VoiceOver work, and + /// not editable, which is what makes it a transcript. + @Test("reads as a selectable transcript rather than an editor") + func isSelectableAndNotEditable() { + let textView = configured() + + #expect(textView.isEditable == false) + #expect(textView.isSelectable) + } + + /// The container follows the view's width so the text re-wraps as the window + /// is resized, and is unbounded in height so the document grows downwards + /// instead of being clipped. + @Test("tracks the view's width and grows without a height limit") + func tracksWidthAndGrowsDown() throws { + let container = try #require(configured().textContainer) + + #expect(container.widthTracksTextView) + #expect(container.size.height == CGFloat.greatestFiniteMagnitude) + // The document's margin is `textContainerInset`; this would add five more + // points inside every line fragment. + #expect(container.lineFragmentPadding == 0) + } + + /// The SwiftUI background behind the pane is the one the design calls for, and + /// AppKit's would paint over it. + @Test("draws no background of its own") + func drawsNoBackground() { + #expect(configured().drawsBackground == false) + } + + /// Contiguous layout is what gives an exact document height, and so a scroll + /// bar that does not shift as it scrolls. Non-contiguous layout is faster to + /// first paint and reports an estimate, which is the thing choosing this stack + /// was meant to avoid. + @Test("lays a TextKit 1 document out contiguously") + func laysOutContiguously() throws { + let textView = NSTextView(usingTextLayoutManager: false) + TranscriptTextView.configure(textView) + + let layout = try #require(textView.layoutManager) + #expect(layout.allowsNonContiguousLayout == false) + } +} diff --git a/apps/macos/Tests/WorkspaceModelTests.swift b/apps/macos/Tests/WorkspaceModelTests.swift new file mode 100644 index 000000000..66959c2c0 --- /dev/null +++ b/apps/macos/Tests/WorkspaceModelTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing + +@testable import JP + +/// The conversation list's state machine. +/// +/// Each load has to land in exactly one state, because the reason this is one +/// value rather than several properties is that several observed mutations in a +/// row make the list reload partway through its own update. +/// +/// Nested in `WorkspaceSuite` because each test points `JP_USER_DATA_DIR` at its +/// own directory, and that variable belongs to the whole process. +extension WorkspaceSuite { + @MainActor + @Suite("WorkspaceModel") + struct WorkspaceModelTests { + + /// Before anything is opened, the sidebar explains how to open something. + @Test("starts by pointing at the Open menu item") + func startsUnopened() { + let model = WorkspaceModel() + + guard case .unavailable(let title, _) = model.state else { + Issue.record("expected an unavailable state, got \(model.state)") + return + } + #expect(title == "No Workspace") + } + + /// An empty workspace is not a failure, and says so differently from one. + @Test("reports an empty workspace as having no conversations") + func reportsAnEmptyWorkspace() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let model = WorkspaceModel() + + await model.open(try makeWorkspace(in: sandbox)) + + guard case .unavailable(let title, _) = model.state else { + Issue.record("expected an unavailable state, got \(model.state)") + return + } + #expect(title == "No Conversations") + } + + @Test("reports a directory that is not a workspace") + func reportsANonWorkspace() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let model = WorkspaceModel() + + await model.open(sandbox.appendingPathComponent("nowhere").path) + + guard case .unavailable(let title, let detail) = model.state else { + Issue.record("expected an unavailable state, got \(model.state)") + return + } + #expect(title == "Could Not Open Workspace") + #expect(detail.hasPrefix("No workspace found")) + } + + /// Reading events needs a workspace, and asking before one is open is a + /// programming mistake worth a message rather than a crash. + @Test("refuses to read events with no workspace open") + func refusesEventsWithoutAWorkspace() async { + let model = WorkspaceModel() + + let result = await model.events(for: "17251488000") + + switch result { + case .success: + Issue.record("expected reading events with no workspace open to fail") + case .failure(let error): + #expect(error.message == "No workspace is open.") + } + } + + /// The path is recorded before the read, so a failed open still leaves the + /// model pointing at what was attempted. + @Test("records the path it was asked to open") + func recordsThePath() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let model = WorkspaceModel() + let path = try makeWorkspace(in: sandbox) + + await model.open(path) + + #expect(model.path == path) + } + + /// Opening a second workspace replaces the first rather than merging them. + @Test("replaces the open workspace") + func replacesTheOpenWorkspace() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let model = WorkspaceModel() + let first = try makeWorkspace(in: sandbox, named: "first") + let second = try makeWorkspace(in: sandbox, named: "second") + + await model.open(first) + await model.open(second) + + #expect(model.path == second) + } + } +} diff --git a/apps/macos/Tests/WorkspaceReaderTests.swift b/apps/macos/Tests/WorkspaceReaderTests.swift new file mode 100644 index 000000000..ee690334e --- /dev/null +++ b/apps/macos/Tests/WorkspaceReaderTests.swift @@ -0,0 +1,258 @@ +import Foundation +import Testing + +@testable import JP + +/// A timings payload, character for character. +/// +/// Pinned here and in `crates/jp_ffi/src/timing_tests.rs`, which asserts the +/// library produces this exact string. Nothing else checks that the two sides +/// agree on the shape: if one of these two literals is edited alone, the other +/// test is what says so. +private let timingsJSON = """ + [{"name":"storage.read","duration_ms":1.234},\ + {"name":"deserialize","duration_ms":84.219},\ + {"name":"serialize","duration_ms":3.0}] + """ + +/// What the library reports about its own work, turned into trace events. +/// +/// Decoding is pure, so these run outside ``WorkspaceSuite`` and in parallel +/// with it. +@Suite("LibraryTimings") +struct LibraryTimingsTests { + /// The nesting is the point. An event recorded with an empty span stack + /// still names `deserialize` and still carries a duration, and says nothing + /// about which piece of app work paid for it — so the whole span stack is + /// compared, not just the message. + /// + /// `3.0` comes back as `3`: `JSONEncoder` drops a trailing zero, and the + /// trace parser reads either as a number. + @Test("nests the library's spans under the app work that asked for them") + func nestsUnderTheEnclosingSpans() { + let lines = WorkspaceReader.timingLines( + Data(timingsJSON.utf8), + under: ["conversation.select", WorkspaceReader.eventsSpan], + at: "2026-08-02T11:04:12.418293Z" + ) + + #expect( + lines == [ + """ + {"timestamp":"2026-08-02T11:04:12.418293Z","level":"INFO","target":"JP.FFI",\ + "fields":{"message":"storage.read","duration_ms":1.234},\ + "spans":[{"name":"conversation.select"},{"name":"workspace.events"}]} + """, + """ + {"timestamp":"2026-08-02T11:04:12.418293Z","level":"INFO","target":"JP.FFI",\ + "fields":{"message":"deserialize","duration_ms":84.219},\ + "spans":[{"name":"conversation.select"},{"name":"workspace.events"}]} + """, + """ + {"timestamp":"2026-08-02T11:04:12.418293Z","level":"INFO","target":"JP.FFI",\ + "fields":{"message":"serialize","duration_ms":3},\ + "spans":[{"name":"conversation.select"},{"name":"workspace.events"}]} + """, + ] + ) + } + + /// A field added on the Rust side must not stop an app built before it from + /// reading the rest. + @Test("ignores a key it does not know") + func ignoresUnknownKeys() { + let lines = WorkspaceReader.timingLines( + Data(#"[{"name":"sort","duration_ms":0.5,"value_count":12}]"#.utf8), + under: ["workspace.open", WorkspaceReader.conversationsSpan], + at: "2026-08-02T11:04:12.418293Z" + ) + + #expect( + lines == [ + """ + {"timestamp":"2026-08-02T11:04:12.418293Z","level":"INFO","target":"JP.FFI",\ + "fields":{"message":"sort","duration_ms":0.5},\ + "spans":[{"name":"workspace.open"},{"name":"workspace.conversations"}]} + """ + ] + ) + } + + /// What a call that failed before doing any of the work it measures reports. + @Test("writes nothing for a call that measured nothing") + func writesNothingForAnEmptyPayload() { + #expect( + WorkspaceReader.timingLines( + Data("[]".utf8), under: ["conversation.select"], + at: "2026-08-02T11:04:12.418293Z" + ).isEmpty + ) + } + + /// Instrumentation nobody can read is not a reason to fail the read it was + /// measuring, so a payload that will not decode is dropped. + @Test("writes nothing for a payload it cannot decode") + func writesNothingForAMalformedPayload() { + #expect( + WorkspaceReader.timingLines( + Data(#"{"name":"sort"}"#.utf8), under: ["conversation.select"], + at: "2026-08-02T11:04:12.418293Z" + ).isEmpty + ) + } +} + +/// End-to-end tests across the FFI boundary: they link the Rust static library +/// and call it, so a failure here means the seam is broken rather than the Swift +/// being wrong. +/// +/// Nested in `WorkspaceSuite` because each test points `JP_USER_DATA_DIR` at its +/// own directory, and that variable belongs to the whole process. +extension WorkspaceSuite { + @Suite("WorkspaceReader") + struct WorkspaceReaderTests { + + /// The phase 2 goal in one assertion: the Rust library links, runs, and its + /// output decodes into Swift values. + @Test("opens a workspace and reads an empty conversation list") + func opensAnEmptyWorkspace() throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeWorkspace(in: sandbox) + + let reader = try WorkspaceReader(path: path) + let conversations = try reader.conversations() + + #expect(conversations.isEmpty) + } + + /// Any directory inside the workspace opens the workspace, so the app can + /// hand over whatever directory the user picked. + @Test("opens a directory inside the workspace") + func opensANestedDirectory() throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let workspace = try makeWorkspace(in: sandbox) + let nested = URL(fileURLWithPath: workspace) + .appendingPathComponent("src/nested") + try FileManager.default.createDirectory( + at: nested, withIntermediateDirectories: true) + + let reader = try WorkspaceReader(path: nested.path) + let conversations = try reader.conversations() + + #expect(conversations.isEmpty) + } + + /// The library's failure message reaches Swift through the thread-local error + /// slot, rather than being lost behind a null return. + /// + /// Assumes no workspace exists above the temporary directory. On a machine + /// where one does, the open succeeds and this fails loudly instead of passing + /// for the wrong reason. + @Test("reports a directory that is not a workspace") + func reportsANonWorkspace() throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeBareDirectory(in: sandbox, named: "not-a-workspace") + + do throws(WorkspaceError) { + let reader = try WorkspaceReader(path: path) + _ = try reader.conversations() + Issue.record("expected opening a bare directory to fail") + } catch { + #expect(error.message == "No workspace found at or above: \(path)") + } + } + + /// A conversation ID that is not a decisecond timestamp is rejected by the + /// library, not by Swift, so this proves the error crosses the boundary. + @Test("reports an unparsable conversation ID") + func reportsAnUnparsableConversationID() throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeWorkspace(in: sandbox) + + do throws(WorkspaceError) { + let reader = try WorkspaceReader(path: path) + _ = try reader.events(for: "not-an-id") + Issue.record("expected an unparsable conversation ID to fail") + } catch { + #expect(error.message.hasPrefix("invalid conversation ID:")) + } + } + + @Test("reports a conversation that is not in the workspace") + func reportsAMissingConversation() throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeWorkspace(in: sandbox) + + do throws(WorkspaceError) { + let reader = try WorkspaceReader(path: path) + _ = try reader.events(for: "17251488000") + Issue.record("expected a missing conversation to fail") + } catch { + #expect(error.message.hasPrefix("conversation not found:")) + } + } + + /// The session the app reads through returns the failure rather than + /// trapping, so a bad path shows up in the UI. + @Test("surfaces a failure through the session") + func sessionSurfacesFailures() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeBareDirectory(in: sandbox, named: "also-not-a-workspace") + + switch await WorkspaceSession.open(path: path) { + case .success: + Issue.record("expected opening a bare directory to fail") + case .failure(let error): + #expect(error.message.hasPrefix("No workspace found")) + } + } + + @Test("reads a workspace through the session") + func sessionReadsAWorkspace() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeWorkspace(in: sandbox) + + guard case .success(let session) = await WorkspaceSession.open(path: path) else { + Issue.record("expected the workspace to open") + return + } + + switch await session.readConversations() { + case .success(let conversations): + #expect(conversations.isEmpty) + case .failure(let error): + Issue.record("expected a successful read, got: \(error.message)") + } + } + + /// The whole point of holding a session open: many reads, one open. A + /// second read must not need the workspace reopened. + @Test("reads repeatedly from one open workspace") + func sessionReadsRepeatedly() async throws { + let sandbox = try makeSandbox() + defer { removeSandbox(sandbox) } + let path = try makeWorkspace(in: sandbox) + + guard case .success(let session) = await WorkspaceSession.open(path: path) else { + Issue.record("expected the workspace to open") + return + } + + for _ in 0..<3 { + switch await session.readConversations() { + case .success(let conversations): + #expect(conversations.isEmpty) + case .failure(let error): + Issue.record("expected a successful read, got: \(error.message)") + } + } + } + } +} diff --git a/apps/macos/Tests/WorkspaceWindowTests.swift b/apps/macos/Tests/WorkspaceWindowTests.swift new file mode 100644 index 000000000..f96975b47 --- /dev/null +++ b/apps/macos/Tests/WorkspaceWindowTests.swift @@ -0,0 +1,154 @@ +import Foundation +import Testing + +@testable import JP + +/// The precedence a window applies when deciding which workspace to show. +/// Pure, so these touch no process state and run in parallel. +@Suite("WorkspaceWindow") +struct WorkspaceWindowTests { + private let recent = URL(fileURLWithPath: "/workspaces/recent") + + @Test("shows nothing when there is nothing to show") + func showsNothingWithoutASource() { + #expect( + WorkspaceWindow.chooseWorkspace(stored: nil, mostRecent: nil, environment: [:]) + == nil + ) + } + + @Test("falls back to the most recently opened workspace") + func fallsBackToTheMostRecent() { + let chosen = WorkspaceWindow.chooseWorkspace( + stored: nil, + mostRecent: recent, + environment: [:] + ) + + #expect(chosen == "/workspaces/recent") + } + + /// Two windows on two workspaces is the point of having windows, so a window + /// that stored a path keeps it rather than following the recents list. + @Test("prefers the window's own stored path over the recents list") + func prefersTheStoredPath() { + let chosen = WorkspaceWindow.chooseWorkspace( + stored: "/workspaces/stored", + mostRecent: recent, + environment: [:] + ) + + #expect(chosen == "/workspaces/stored") + } + + /// The regression that made `just run-app ` a no-op after the first + /// run: once a window had stored a path, the environment was never read again. + @Test("prefers JP_WORKSPACE over a stored path") + func prefersTheEnvironmentOverAStoredPath() { + let chosen = WorkspaceWindow.chooseWorkspace( + stored: "/workspaces/stored", + mostRecent: recent, + environment: ["JP_WORKSPACE": "/workspaces/named"] + ) + + #expect(chosen == "/workspaces/named") + } + + @Test("prefers JP_WORKSPACE over the recents list") + func prefersTheEnvironmentOverTheRecentsList() { + let chosen = WorkspaceWindow.chooseWorkspace( + stored: nil, + mostRecent: recent, + environment: ["JP_WORKSPACE": "/workspaces/named"] + ) + + #expect(chosen == "/workspaces/named") + } + + /// The app's own scheme sets `JP_WORKSPACE` to an empty string when no + /// workspace is configured, which must not beat a real stored path. + @Test("ignores an empty JP_WORKSPACE") + func ignoresAnEmptyEnvironmentValue() { + let chosen = WorkspaceWindow.chooseWorkspace( + stored: "/workspaces/stored", + mostRecent: recent, + environment: ["JP_WORKSPACE": ""] + ) + + #expect(chosen == "/workspaces/stored") + } + + @Test("ignores an empty stored path") + func ignoresAnEmptyStoredPath() { + let chosen = WorkspaceWindow.chooseWorkspace( + stored: "", + mostRecent: recent, + environment: [:] + ) + + #expect(chosen == "/workspaces/recent") + } +} + +/// What the focused window offers the menu bar. +/// +/// The equality is the whole of it, and it carries more weight than a reader +/// would guess: see ``WorkspaceActions`` for what a value that differs on every +/// render does to the app. +@Suite("WorkspaceActions") +struct WorkspaceActionsTests { + private func actions( + windowID: UUID, + hasSelection: Bool = false, + isSidebarVisible: Bool = true + ) -> WorkspaceActions { + WorkspaceActions( + windowID: windowID, + hasSelection: hasSelection, + isSidebarVisible: isSidebarVisible, + choose: {}, + open: { _ in }, + copyLinks: {}, + toggleSidebar: {} + ) + } + + /// The one that matters. A window republishes this on every render with + /// fresh closures, and closures never compare equal, so comparing them would + /// invalidate the whole scene continuously. + @Test("a republished value from the same window compares equal") + func republishingIsNotAChange() { + let window = UUID() + + #expect(actions(windowID: window) == actions(windowID: window)) + } + + @Test("a value from another window differs") + func anotherWindowDiffers() { + #expect(actions(windowID: UUID()) != actions(windowID: UUID())) + } + + /// A menu item conditioned on the selection has to be re-evaluated when the + /// selection appears, and equality is the only thing that asks for it. + @Test("gaining a selection is a change") + func gainingASelectionIsAChange() { + let window = UUID() + + #expect( + actions(windowID: window, hasSelection: false) + != actions(windowID: window, hasSelection: true) + ) + } + + /// The View menu's item is titled from this, so hiding the sidebar has to be + /// a change or the item keeps saying Hide when it means Show. + @Test("hiding the sidebar is a change") + func hidingTheSidebarIsAChange() { + let window = UUID() + + #expect( + actions(windowID: window, isSidebarVisible: true) + != actions(windowID: window, isSidebarVisible: false) + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Package.swift b/apps/macos/Tools/jpdrive/Package.swift new file mode 100644 index 000000000..5e3cee840 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +// Mirrors the app's `project.yml`: an existential is spelled `any P`, and a +// warning that never fails a build is a warning nobody fixes. +// +// SwiftPM 6.0 has no first-class setting for warnings-as-errors, and +// `unsafeFlags` is rejected only for a package consumed as a dependency, which +// this one never is. +let strict: [SwiftSetting] = [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .unsafeFlags(["-warnings-as-errors"]), +] + +let package = Package( + name: "jpdrive", + platforms: [.macOS(.v15)], + products: [ + .executable(name: "jpdrive", targets: ["jpdrive"]) + ], + targets: [ + // The driver's logic, in a library so it can be tested. SwiftPM cannot + // cleanly test an executable target, and the traversal is where the bugs + // are. + .target(name: "DriveKit", swiftSettings: strict), + + // One line, calling into the library. + .executableTarget(name: "jpdrive", dependencies: ["DriveKit"], swiftSettings: strict), + + .testTarget(name: "DriveKitTests", dependencies: ["DriveKit"], swiftSettings: strict), + ] +) diff --git a/apps/macos/Tools/jpdrive/README.md b/apps/macos/Tools/jpdrive/README.md new file mode 100644 index 000000000..58206ee13 --- /dev/null +++ b/apps/macos/Tools/jpdrive/README.md @@ -0,0 +1,298 @@ +# jpdrive + +Reads and acts on a running macOS app's accessibility tree, speaking JSON. The +`debug_app_*` tools shell out to it; the Rust side stays the presenter, parsing +the JSON and rendering markdown. + +Swift rather than Rust because `AXUIElement` is CoreFoundation-shaped: ordinary +code here, unsafe bindings or a 784-download crate there. + +External rather than an in-app automation socket, deliberately. Driving through +`AXUIElement` means a broken accessibility tree breaks the tooling, which is the +pressure that keeps the app's accessibility honest. + +## Build + +```sh +just build-drive +``` + +The binary lands at `.build/release/jpdrive` under this directory. + +## The TCC question + +Everything downstream depends on one unknown: does a binary launched as a child +of `just serve-tools` inherit the Accessibility grant given to the terminal? + +macOS attributes TCC to the *responsible process*, which for a command-line tool +is normally the terminal rather than the tool. That is the same mechanism behind +the `sample(1)` note in `.config/jp/tools/src/debug_jp/profile_sampling.rs` about +granting Terminal *Developer Tools*. Apple documents neither the algorithm nor +its stability, so the answer has to be measured. + +`jpdrive doctor` measures it. Run it three ways, with Accessibility granted to +the terminal application and the app running: + +Check the target first. An empty `pgrep` means the app is not running, and a +run without a target reports the trust flag alone, which is the half of the +answer that can be wrong: + +```sh +pgrep -f JP.app # must print exactly one pid +``` + +```sh +# 1. Directly from the terminal. +.build/release/jpdrive doctor --pid $(pgrep -f JP.app) + +# 2. Through just, which adds the process layer the tools will run under. +just drive-doctor $(pgrep -f JP.app) +``` + +The third case, a child of `jp-tools` under `just serve-tools`, needs a tool that +shells out to the driver. Reaching it means writing the first `debug_app_*` tool, +which is why cases 1 and 2 come first: if the grant already fails at case 2, +nothing is learned by going further. + +Compare `trusted` and `probe.axError` across the runs. `trusted: true` with a +window count means the grant inherits. `trusted: false`, or `api_disabled` / +`cannot_complete` from the probe, means it does not, and the driver needs its own +signed bundle or its own grant. + +The report lists the ancestor chain, so a `false` says which processes were +candidates for holding the grant. + +The check never prompts. `AXIsProcessTrustedWithOptions` with +`kAXTrustedCheckOptionPrompt` would raise the system dialog and change the state +being measured. + +### Result + +**The grant inherits.** With Accessibility granted to Ghostty, case 2 reports +`trusted: true` and a window count from a chain of six: + +``` +ghostty → login → fish → just → sh → jpdrive +``` + +So `tree`, `windows`, `menu`, and `act` need no signed bundle and no grant of +their own. They can assume the terminal's. Case 3, a child of `jp-tools` under +`just serve-tools`, adds one more process of the same kind and is still +unmeasured. + +Observations across the runs, on macOS with Ghostty as the terminal: + +- Process depth is not the variable. Run directly from the shell (chain of four, + up to `ghostty`) and through `just` (chain of six, adding `sh` and `just`), the + report is identical. Whatever governs the grant, it is not the number of + processes between the terminal and the driver. +- The trust flag and the probe agree. `trusted: false` came with + `ax_error: api_disabled` from a real read against a running app, which is what + the accessibility API returns to an untrusted caller; `trusted: true` came with + a window count. No case has been seen where the two disagree. +- Untested: a terminal instance started *before* the grant. The `false` runs and + the `true` run may differ by the grant alone, by a relaunch, or by both, so + "the grant is not visible to this terminal instance" is not yet ruled out as a + separate failure mode. + +## Screen Recording is a second grant + +`windowid` answers the window server rather than the accessibility API, and the +two are governed by different TCC grants. Enumerating windows needs neither, so +the command works with nothing granted at all; reading a window's *title*, and +capturing its content with `screencapture -l`, need Screen Recording. + +That is why the report pairs the list with a `screen_recording` flag rather than +refusing outright. Missing the grant, a capture succeeds and returns the desktop +where the window should be, so the caller has to know before it writes a file. +An untitled window in the list is the same fact seen from the other side. + +The pane is System Settings ▸ Privacy & Security ▸ Screen & System Audio +Recording, and as with Accessibility it is the terminal application that needs +it, not the driver. + +### Result + +**The grant inherits.** Measured with Ghostty as the terminal, the driver run as +a child of `jp-tools` under `just serve-tools`: before the grant, +`screen_recording` came back `false` and `debug_app_screenshot` refused; after +granting Screen Recording to Ghostty and restarting it, the same call captured +the window. + +So the flag is worth trusting, and this grant reaches a driver six processes +deep from the terminal, same as Accessibility does. + +Untested: whether the restart was necessary. The grant and the restart happened +together, so nothing here separates them. + +## What the sidebar looks like through accessibility + +SwiftUI's `.accessibilityIdentifier` does not land on the element that owns +behaviour. For a `List` row it lands two levels below it: + +``` +AXOutline AXIdentifier: sidebar.list AXRows: 1065, AXVisibleRows: 9 + AXRow AXSelected settable: true + AXCell AXSelected settable: false, AXScrollToVisible settable: true + AXUnknown AXIdentifier: sidebar.row. + AXAttributedDescription: ", <n> events" + no actions, no children +``` + +So addressing an element and acting on it are two different steps. The identified +element has no actions at all: no `AXPress`, nothing. Selecting a row means +walking up to the `AXRow` and writing `AXSelected`. + +That write is preferable to a synthesized click for a reason beyond determinism. +Every row exists as an accessibility element, but only nine are on screen: the +outline's frame is 41658pt tall against a 398pt viewport. A click at +`AXActivationPoint` would miss an off-screen row, or land on whichever row +occupies those coordinates instead. An `AXSelected` write is independent of +scroll position. + +`AXScrollToVisible` appears as a settable *attribute* on a sidebar cell and as an +*action* on a transcript event, so scrolling has to try both forms. + +The sidebar materialises every row; the transcript does not. Only one +`transcript.event.*` element exists at a time, so an identifier that names an +unrendered event cannot be waited for, only scrolled to. + +Writing `AXSelected` on a row 690 places down a thousand-row list selects it and +brings it into view, so selecting a row needs no scrolling step of its own. The +transcript still does. + +### The identified element cannot be walked upwards + +The `AXUnknown` carrying the identifier reports no `AXParent`, and no +`AXTopLevelUIElement` either, unlike the cell and row above it. Climbing from it +arrives nowhere. + +So resolving an identifier means keeping the chain the search descended through, +not finding the element and navigating from it afterwards. Anything that acts on +an ancestor of an identified element depends on this. + +### Cost + +An accessibility round-trip to this app costs roughly 3ms, and that number sets +every other budget: + +- Reading the first few rows under `sidebar.` takes 250ms. +- Finding one row 690 places down takes 5.8s, because the search reads about two + thousand elements to get there and cannot prune on the way: every identifier in + the sidebar sits on a leaf. + +Hence the batched reads and the match budget. Anything that polls should resolve +an element once and re-read that reference, rather than searching each time. + +## Acting on an element + +Each step names exactly one mechanism, because the mechanism depends on what the +element is and guessing hides regressions: + +| step | addressed by | mechanism | +| --- | --- | --- | +| `select` | identifier | write `AXSelected` on the nearest ancestor accepting it | +| `press` | identifier | `AXPress` on the element itself | +| `type` | identifier | write `AXValue`, then `AXConfirm` | +| `perform` | identifier | a named action, for the verbs with no step of their own | +| `menu` | titled path | `AXPress` on the item the path resolves to | +| `click` | identifier | synthesized mouse event at `AXActivationPoint` | + +`press` and `menu` end in the same call and are not redundant: they differ in what +they address by, and that is what a test pins. `closeAll:` is an `AppKit` selector +name that survives the item moving to another menu, so a script keyed on it cannot +notice the menu bar being rearranged. `["File", "Close All"]` names the structure +the user sees, and a path that stops resolving reports how far it got and what that +level holds instead — which is the assertion failure a layout test wants to read. + +A step that names the wrong mechanism fails and says which actions the element +does accept. There is no fallback chain: if a sidebar row stopped accepting +`AXSelected`, a driver that quietly fell back to a synthesized click would keep +every script green while the app's accessibility rotted, which is the failure this +tool exists to prevent. + +`select` and `type` read the attribute back afterwards, because a write can be +accepted and discarded. `press` cannot: nothing observable says a button did +anything, so its result reports no confirmation rather than claiming one. + +### A menu step has to bring the app forward + +`menu` writes `AXFrontmost` on the application and waits for it to take, which +makes it the one step that takes focus from whatever had it. + +Without it almost nothing in the menu bar can be pressed. AppKit disables every +item that acts on the front window or the responder chain while the application +is in the background, and against a driven instance that is most of the bar: +`Close`, `Copy`, `Select All`, `Show Sidebar`, and every `SwiftUI` command +reading a `@FocusedValue` all report `AXEnabled: 0`. `New Window` and `Close +All` do not, which is what makes the difference easy to miss — a first menu step +against an app-level item works, and the next one silently does nothing. + +So the item's enabled state is checked before it is pressed, rather than +trusting `AXPress` to report a refusal. A disabled item accepts the press and +answers success. + +An element that reports no `AXEnabled` at all is not disabled. Plenty carry no +such attribute, and reading its absence as a refusal would reject them all. + +### Typing writes the value, and then has to commit it + +`type` writes `AXValue` and performs `AXConfirm`. Both are needed, and the second +one is the part that was not obvious. + +Writing `AXValue` on a `SwiftUI` `TextField` changes the text the field displays +and leaves the binding behind it untouched. Measured against the conversation +filter: after the write the field read back `"accessibility"` and the list still +showed all 1,066 rows. Deleting one character by hand then filtered on +`"accessibilit"` — the keystroke made the binding resync from whatever the field +held by then. So a `type` that only wrote the value would report success while the +application carried on as though nothing had been typed. + +`AXConfirm` commits through the path the binding observes. A field advertising no +confirm action is not a failure — some publish every change as it happens — so the +result reports `committed` separately from `confirmed`: the text being in the field +and the application having seen it are different facts. + +Synthesizing key events was rejected on three counts: the events go wherever focus +is, so a window activating mid-sequence types into it instead; posting them fast +enough to be useful means pauses between characters, which makes the step flaky +rather than deterministic; and event posting is global process state, so it could +not sit behind the element abstraction the rest of the driver is tested through. + +The remaining cost is that per-character behaviour never runs. A field that +validates each keystroke, or completes as you type, sees one change rather than a +dozen. + +### Clicking is the last resort + +`click` raises the element's window and posts a mouse event at its +`AXActivationPoint`. It is the only step whose effect is not addressed to an +element: the event goes to whatever occupies that screen coordinate, which is why +the window is raised first and why an occluding window from another application +will still swallow it. + +An element reporting no activation point is refused rather than clicked at the +origin. A sidebar row is exactly that case, and it wants `select`. + +Posting is behind an `EventPoster`, so where the driver aimed can be asserted in a +test even though where the event lands cannot. + +### Apple Events are a separate pathway, and that one does not inherit + +Reading the same tree through AppleScript fails from the terminal the driver +succeeds from: + +``` +System Events got an error: osascript is not allowed assistive access. (-1719) +``` + +Two different checks. `AXIsProcessTrusted`, which the driver calls, resolves to +the responsible process and finds the terminal. `System Events` requires the +calling binary itself to be listed, and the calling binary is `/usr/bin/osascript` +— shared by everything on the machine, so granting it grants far more than the +driver needs. + +This is the second reason the driver is a binary of its own rather than a shell +script over `osascript`, alongside the one at the top of this file. It also means +AppleScript is not a fallback when the driver is missing a verb: the verb has to +be added here. diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/AXElement.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/AXElement.swift new file mode 100644 index 000000000..4ee15b376 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/AXElement.swift @@ -0,0 +1,359 @@ +import ApplicationServices +import Foundation + +/// An element of a running application's accessibility tree. +/// +/// Every method here is one or more synchronous round-trips to the target's main +/// thread. That cost dominates everything the driver does, so callers batch reads +/// with ``values(_:)`` rather than reading attributes one at a time, and hold onto +/// an element they will read again instead of walking to it twice. +/// +/// A reference stays valid while the underlying element lives. Once it is gone, +/// reads answer `invalid_ui_element` rather than crashing. +struct AXElement { + let element: AXUIElement + + /// The root element of the application owning `pid`. + /// + /// Succeeds whether or not the process exists; the first read is what fails. + static func application(pid: pid_t) -> AXElement { + return AXElement(element: AXUIElementCreateApplication(pid)) + } + + /// `AXRole`, or `nil` when the element does not report one. + var role: String? { + return read(kAXRoleAttribute).flatMap { $0 as? String } + } + + /// `AXIdentifier`, or `nil` when the element carries none. + /// + /// SwiftUI's `.accessibilityIdentifier` surfaces here, but it also composites + /// with identifiers the framework generates itself, so a value like + /// `"workspace-AppWindow-1, SidebarNavigationSplitView"` is possible. + var identifier: String? { + return read(kAXIdentifierAttribute).flatMap { $0 as? String } + } + + /// The element's human-readable label. + /// + /// Tries `AXAttributedDescription`, then `AXDescription`, then `AXTitle`. + /// SwiftUI populates the first of those for list rows and leaves the others + /// empty, while AppKit controls tend to do the reverse. + var label: String? { + if let attributed = read(Self.attributedDescription) as? NSAttributedString { + return attributed.string + } + + for name in [kAXDescriptionAttribute, kAXTitleAttribute] { + guard let text = read(name).flatMap({ $0 as? String }), !text.isEmpty else { + continue + } + return text + } + + return nil + } + + /// `AXAttributedDescription`, which has no constant in the SDK headers. + static let attributedDescription = "AXAttributedDescription" + + /// `AXActivationPoint`, which has no constant in the SDK headers. + /// + /// Where the element says a click on it belongs, in screen coordinates. Not + /// always the middle of its frame. + static let activationPoint = "AXActivationPoint" + + /// The element's children, or an empty array when it has none. + /// + /// A round-trip of its own. A walk should take children from ``read(_:)``, + /// which fetches them alongside everything else it needs. + var children: [AXElement] { + guard let value = read(kAXChildrenAttribute), let raw = value as? [AXUIElement] else { + return [] + } + return raw.map { AXElement(element: $0) } + } + + /// Actions the element accepts, such as `AXPress`. + var actions: [String] { + var names: CFArray? + guard AXUIElementCopyActionNames(element, &names) == .success, + let names = names as? [String] + else { + return [] + } + return names + } + + /// Every attribute name the element advertises. + func names() -> [String] { + var names: CFArray? + guard AXUIElementCopyAttributeNames(element, &names) == .success, + let names = names as? [String] + else { + return [] + } + return names + } + + /// Read one attribute, or `nil` when the read fails or the value is absent. + /// + /// Use ``values(_:)`` when reading more than one: this costs a round-trip per + /// call, which is what makes a naive tree walk take seconds. + func read(_ name: String) -> CFTypeRef? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success + else { + return nil + } + guard let value, CFGetTypeID(value) != CFNullGetTypeID() else { return nil } + return value + } + + /// Read several attributes in one round-trip. + /// + /// Results are positional and the same count as `names`. An attribute that + /// could not be read arrives as CoreFoundation's null or as an `AXValue` + /// boxing the error, both of which ``text(_:)`` reports rather than discards. + func values(_ names: [String]) -> [CFTypeRef?] { + guard !names.isEmpty else { return [] } + + var raw: CFArray? + let status = AXUIElementCopyMultipleAttributeValues( + element, + names as CFArray, + AXCopyMultipleAttributeOptions(), + &raw + ) + + guard status == .success, + let values = raw as? [CFTypeRef], + values.count == names.count + else { + return Array(repeating: nil, count: names.count) + } + + return values + } + + /// Whether `name` can be written on this element. + /// + /// A failed query reports as not settable: the accessibility API answers this + /// for every attribute it advertises, so a failure means the element is gone + /// or the attribute is not really there. + func isSettable(_ name: String) -> Bool { + var settable = DarwinBoolean(false) + guard AXUIElementIsAttributeSettable(element, name as CFString, &settable) == .success + else { + return false + } + return settable.boolValue + } + + /// Perform `action`, returning the API's own status. + func perform(_ action: String) -> AXError { + return AXUIElementPerformAction(element, action as CFString) + } +} + +extension AXElement: Element { + /// Read the named attributes and the element's children in one round-trip. + /// + /// Children come back in the same batch as everything else: asking for them + /// separately would add a hop per element, and every walk asks for them. + func read(_ names: [String]) -> Reading<AXElement> { + let values = self.values(names + [kAXChildrenAttribute]) + + let children = (values.last.flatMap { $0 } as? [AXUIElement] ?? []) + .map { AXElement(element: $0) } + + return Reading( + text: values.dropLast().map(Self.optionalText), + children: children + ) + } + + /// Read a boolean attribute. + /// + /// `CFBoolean` bridges to `NSNumber` rather than to `Bool`, so a direct cast + /// answers `nil` for a perfectly good `0` or `1`. + func flag(_ name: String) -> Bool? { + guard let value = read(name) as? NSNumber else { return nil } + return value.boolValue + } + + /// Write a boolean attribute, answering the API's own status. + func setFlag(_ name: String, _ value: Bool) -> AXError { + return AXUIElementSetAttributeValue( + element, + name as CFString, + value ? kCFBooleanTrue : kCFBooleanFalse + ) + } + + /// The point held in an attribute, in screen coordinates. + func point(_ name: String) -> CGPoint? { + guard let value = read(name), CFGetTypeID(value) == AXValueGetTypeID() else { + return nil + } + + let boxed = unsafeDowncast(value, to: AXValue.self) + guard AXValueGetType(boxed) == .cgPoint else { return nil } + + var point = CGPoint.zero + guard AXValueGetValue(boxed, .cgPoint, &point) else { return nil } + + return point + } + + /// The size held in an attribute, in points. + func size(_ name: String) -> CGSize? { + guard let value = read(name), CFGetTypeID(value) == AXValueGetTypeID() else { + return nil + } + + let boxed = unsafeDowncast(value, to: AXValue.self) + guard AXValueGetType(boxed) == .cgSize else { return nil } + + var size = CGSize.zero + guard AXValueGetValue(boxed, .cgSize, &size) else { return nil } + + return size + } + + /// Write a size attribute, answering the API's own status. + /// + /// The value has to be boxed in an `AXValue`: the API takes `CFTypeRef` and a + /// bare `CGSize` is not one, so passing it any other way fails the write with + /// no indication of why. + func setSize(_ name: String, _ value: CGSize) -> AXError { + var size = value + guard let boxed = AXValueCreate(.cgSize, &size) else { + return .failure + } + + return AXUIElementSetAttributeValue(element, name as CFString, boxed) + } + + /// Write a string attribute, answering the API's own status. + func setText(_ name: String, _ value: String) -> AXError { + return AXUIElementSetAttributeValue(element, name as CFString, value as CFString) + } + + /// The elements held in an attribute. + func elements(_ name: String) -> [AXElement] { + guard let value = read(name) else { return [] } + + if let raw = value as? [AXUIElement] { + return raw.map { AXElement(element: $0) } + } + + guard CFGetTypeID(value) == AXUIElementGetTypeID() else { return [] } + return [AXElement(element: unsafeDowncast(value, to: AXUIElement.self))] + } +} + +extension AXElement { + /// Render an attribute value as text, or `nil` when there is no value. + /// + /// A batched read answers an absent attribute with CoreFoundation's null and an + /// unreadable one with a boxed error. Both are facts a dump wants to see and a + /// caller reading one attribute wants as nothing at all. + static func optionalText(_ value: CFTypeRef?) -> String? { + guard let value, CFGetTypeID(value) != CFNullGetTypeID() else { return nil } + + if CFGetTypeID(value) == AXValueGetTypeID(), + AXValueGetType(unsafeDowncast(value, to: AXValue.self)) == .axError + { + return nil + } + + return text(value) + } + + /// Render an attribute value as text. + /// + /// Values arrive as CoreFoundation types, including geometry boxed in + /// `AXValue` and references to other elements. Everything becomes a string so + /// that a reader can see which attributes exist and which carry identifiers + /// without this growing a case per boxed type. + static func text(_ value: CFTypeRef) -> String { + // An attribute the element advertises but cannot answer for, such as + // `AXSubrole` on an element that has none. + if CFGetTypeID(value) == CFNullGetTypeID() { + return "<null>" + } + if let text = value as? String { + return text + } + // Labels arrive as attributed strings more often than plain ones, and the + // attributes carry nothing the driver acts on. + if let attributed = value as? NSAttributedString { + return attributed.string + } + if let number = value as? NSNumber { + return number.stringValue + } + if let elements = value as? [AXUIElement] { + return "<\(elements.count) AXUIElement>" + } + if let array = value as? [Any] { + return "<array of \(array.count)>" + } + + let typeID = CFGetTypeID(value) + if typeID == AXUIElementGetTypeID() { + return "<AXUIElement>" + } + if typeID == AXValueGetTypeID() { + // The conditional form is rejected here: every CoreFoundation type is + // bridged as a class, so the compiler sees a cast that cannot fail. + // The type ID check above is the real test. + return text(unsafeDowncast(value, to: AXValue.self)) + } + return "<CFTypeID \(typeID)>" + } + + /// Render the geometry boxed in an `AXValue`. + /// + /// `AXActivationPoint` and `AXFrame` decide where a synthesized click lands, + /// so these arrive as numbers a reader can check against the screen rather + /// than as an opaque marker. + static func text(_ value: AXValue) -> String { + let type = AXValueGetType(value) + + switch type { + // A batched read reports a per-attribute failure by boxing the error + // rather than by failing the whole call. + case .axError: + var status = AXError.success + guard AXValueGetValue(value, .axError, &status) else { break } + return "<\(status.name)>" + + case .cgPoint: + var point = CGPoint.zero + guard AXValueGetValue(value, .cgPoint, &point) else { break } + return "\(point.x),\(point.y)" + + case .cgSize: + var size = CGSize.zero + guard AXValueGetValue(value, .cgSize, &size) else { break } + return "\(size.width)x\(size.height)" + + case .cgRect: + var rect = CGRect.zero + guard AXValueGetValue(value, .cgRect, &rect) else { break } + return "\(rect.origin.x),\(rect.origin.y) \(rect.size.width)x\(rect.size.height)" + + case .cfRange: + var range = CFRange() + guard AXValueGetValue(value, .cfRange, &range) else { break } + return "\(range.location)+\(range.length)" + + default: + break + } + + return "<AXValue \(type.rawValue)>" + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/AXErrorName.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/AXErrorName.swift new file mode 100644 index 000000000..68add5ffe --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/AXErrorName.swift @@ -0,0 +1,24 @@ +import ApplicationServices + +extension AXError { + /// A stable snake_case name for this error. + /// + /// Only the codes a read or an action can realistically produce are named. + /// Anything else keeps its numeric code rather than being flattened into + /// "unknown", so an unexpected failure stays traceable to a header. + var name: String { + switch self { + case .success: return "success" + case .apiDisabled: return "api_disabled" + case .cannotComplete: return "cannot_complete" + case .invalidUIElement: return "invalid_ui_element" + case .notImplemented: return "not_implemented" + case .attributeUnsupported: return "attribute_unsupported" + case .actionUnsupported: return "action_unsupported" + case .noValue: return "no_value" + case .illegalArgument: return "illegal_argument" + case .failure: return "failure" + default: return "ax_error_\(rawValue)" + } + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Act.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Act.swift new file mode 100644 index 000000000..2faf78dc7 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Act.swift @@ -0,0 +1,1218 @@ +import ApplicationServices +import Foundation + +/// One thing to do to one element. +/// +/// Each case names its own mechanism. There is no step that picks a mechanism +/// based on what the element supports: a script that says `select` against +/// something unselectable fails and says so, which is how a change in the app's +/// accessibility becomes visible instead of being absorbed by a fallback. +/// +/// Decoded from a single-key object, so a step reads as what it does: +/// +/// ```json +/// {"select": {"identifier": "sidebar.row.17855681129"}} +/// ``` +enum Step: Decodable { + /// Write `AXSelected` on the nearest ancestor that accepts it. + /// + /// The mechanism for list and outline rows, where the identified element is + /// below the one that owns selection. Independent of scroll position, so it + /// reaches a row that is not on screen. + case select(Target) + + /// Perform `AXPress` on the identified element itself. + /// + /// The mechanism for buttons and menu items, which advertise the action. + case press(Target) + + /// Synthesize a mouse click at the element's activation point. + /// + /// The last resort, and the only step that depends on the world outside the + /// accessibility tree: the window has to be frontmost and the element on + /// screen, or the click lands somewhere else entirely. + case click(Target) + + /// Perform a named accessibility action on the identified element. + /// + /// The long tail. `press` is this with `AXPress` and a better error message, + /// and is worth keeping because it is the overwhelmingly common case; anything + /// else an element advertises — `AXConfirm`, `AXShowMenu`, `AXScrollToVisible`, + /// `AXCancel` — is reached through here rather than by growing a step per verb. + case perform(ActionTarget) + + /// Put text into a text field. + case type(TypeTarget) + + /// Set an element's size, which for a window resizes it. + /// + /// The one step that changes the shape of what is on screen rather than what + /// is in it, and the only way to observe what a resize costs: a drag of a + /// window's edge cannot be synthesized against a background application, and + /// resizing is where a view that re-measures its contents shows up. + case resize(SizeTarget) + + /// Drag the pointer across an element, with the button held. + /// + /// The gesture no other step can stand in for. `resize` sets a window's size + /// in one write, which is not a drag: nothing enters live resize, and a view + /// that behaves differently *during* a gesture than after it looks correct to + /// every other step here. + /// + /// Not only for window edges. Any two points on any element — a split + /// divider's handle, a stretch of text to select, a row to drag out — is the + /// same gesture with different endpoints. + /// + /// Depends on the world outside the tree in the same way `click` does: the + /// events go to whatever occupies those coordinates, so the window is raised + /// first and has to be on screen. + case drag(DragTarget) + + /// What to drag across, and along what path. + struct DragTarget: Decodable { + /// The element's `AXIdentifier`, matched exactly. + /// + /// Names the coordinate space, not necessarily the thing that reacts. A + /// window's own frame is how its resize corner is addressed, and the + /// window is what reacts. + let identifier: String + + /// Where the button goes down, as a fraction of the element's frame. + let from: Offset + + /// Where it comes up. + let to: Offset + + /// How many moves to post between the two, not counting the press. + /// + /// Defaults to 24. The number is the point of the step: a drag posted as + /// one jump exercises a single frame, and the behaviour usually under + /// question is what happens across many. + let steps: Int? + + /// How long to pause between moves, in milliseconds. Defaults to 8. + let pauseMs: Int? + + private enum CodingKeys: String, CodingKey { + case identifier + case from + case to + case steps + case pauseMs = "pause_ms" + } + } + + /// A point on an element, as a fraction of its frame. + /// + /// Fractions rather than points, so a script says "the right edge, halfway + /// down" and keeps meaning it after the window is resized. + /// + /// `1.0` is the far edge exactly, and is what a window resize wants. The + /// region that resizes a window is a few points wide and straddles the frame + /// boundary, so aiming even five points inside lands in the content instead: + /// the gesture runs, the pointer moves, and whatever is under it gets dragged + /// rather than the window resized. Measured on a running window — `0.995` of + /// a 1070-point window grabs text, `1.0` grabs the edge. + struct Offset: Decodable { + let dx: Double + let dy: Double + } + + /// What to resize, and to what. + struct SizeTarget: Decodable { + /// The element's `AXIdentifier`, matched exactly. + /// + /// A window carries one, so it is addressed the same way as anything else + /// rather than through a step that means "the frontmost window". + let identifier: String + + /// The width to ask for, in points. + let width: Double + + /// The height to ask for, in points. + let height: Double + } + + /// What action to perform, and on what. + struct ActionTarget: Decodable { + /// The element's `AXIdentifier`, matched exactly. + let identifier: String + + /// The action's own name, spelled as the accessibility API spells it. + /// + /// Not translated from a friendlier vocabulary: a step that says + /// `AXConfirm` can be checked against what `jpdrive dump` reported for the + /// element, and a friendlier name could not. + let action: String + } + + /// Press a menu item, addressed by the titles leading to it. + case menu(MenuTarget) + + /// What to type, and where. + struct TypeTarget: Decodable { + /// The field's `AXIdentifier`, matched exactly. + let identifier: String + + /// The text to put in the field, replacing what is there. + /// + /// Written as a value and then confirmed, rather than typed a character at + /// a time. Two calls, neither of which can be derailed by focus moving to + /// another application halfway through, which a synthesized keystroke can. + /// + /// The confirm is not optional dressing. Writing `AXValue` on a `SwiftUI` + /// text field changes the text the field displays without the binding + /// behind it noticing, so the application carries on as though nothing was + /// typed. Confirming commits the edit through the path the binding does + /// observe. + /// + /// The cost is that per-character behaviour never runs. A field that + /// validates each keystroke, or completes as you type, sees one change + /// rather than a dozen. Assert the consequence — the list that narrowed, + /// the button that enabled — rather than assuming the field's own handlers + /// fired for every character. + let text: String + } + + /// Wait until an element with the given identifier exists. + case waitFor(WaitTarget) + + /// A path through a menu. + struct MenuTarget: Decodable { + /// Titles from the top of the menu downwards, such as `["File", "Close"]`. + /// + /// Titles rather than identifiers, because the structure is the thing worth + /// asserting. An identifier like `closeAll:` is an `AppKit` selector name: + /// it survives the item moving to a different menu, so a script keyed on it + /// cannot notice the menu bar being rearranged. A path cannot miss that. + /// + /// A path that does not resolve reports how far it got and what that level + /// holds, which is the assertion failure a layout test wants to read. + let path: [String] + + /// The element whose shown menu the path starts from. + /// + /// Absent, the path starts at the menu bar. Present, it starts at the menu + /// that element is currently displaying, which `AXShowMenu` puts up. + /// + /// A title is the only way to name a context menu item: `SwiftUI` does not + /// carry an accessibility identifier onto the `NSMenuItem` it bridges a + /// menu button to, so every item in one reports the same selector name. + let under: String? + + /// Spelled out so `under` can be left off, both here and on the wire. + init(path: [String], under: String? = nil) { + self.path = path + self.under = under + } + } + + /// What a wait addresses, and for how long. + struct WaitTarget: Decodable { + /// The `AXIdentifier` to wait for, matched exactly. + let identifier: String + + /// Identifier of a container to search inside, resolved once before + /// polling begins. + /// + /// Strongly worth setting. A search for something absent has no early exit + /// and reads every element in the application, which against a thousand-row + /// sidebar takes longer than a typical timeout allows for a single attempt. + /// Scoping to the container the element will appear in makes each poll + /// cheap. + /// + /// A container that does not exist fails immediately, rather than being + /// waited for. + let under: String? + + /// How long to keep trying. Defaults to 5000. + let timeoutMs: Int? + + /// How long to pause between attempts. Defaults to 100. + /// + /// Not the kind of sleep the driver avoids. Waiting a fixed duration and + /// assuming the work finished is a guess; pausing between two observations + /// of a condition is how polling stays off a busy loop that would flood the + /// target with accessibility traffic. + let intervalMs: Int? + + /// Spelled out, because the decoder converts no cases of its own: without + /// these two, a step naming `timeout_ms` decodes as though it had named + /// nothing and silently waits the default. + private enum CodingKeys: String, CodingKey { + case identifier + case under + case timeoutMs = "timeout_ms" + case intervalMs = "interval_ms" + } + } + + /// What a step addresses. + struct Target: Decodable { + /// The element's `AXIdentifier`, matched exactly. + /// + /// Exact rather than by prefix: `sidebar.row.1785` is a prefix of many + /// rows, and acting on whichever one happened to be found first is not a + /// thing a script can mean. + let identifier: String + } + + private enum CodingKeys: String, CodingKey { + case select + case press + case click + case perform + case type + case menu + case waitFor = "wait_for" + case resize + case drag + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let target = try container.decodeIfPresent(Target.self, forKey: .select) { + self = .select(target) + return + } + if let target = try container.decodeIfPresent(Target.self, forKey: .press) { + self = .press(target) + return + } + if let target = try container.decodeIfPresent(Target.self, forKey: .click) { + self = .click(target) + return + } + if let target = try container.decodeIfPresent(ActionTarget.self, forKey: .perform) { + self = .perform(target) + return + } + if let target = try container.decodeIfPresent(TypeTarget.self, forKey: .type) { + self = .type(target) + return + } + if let target = try container.decodeIfPresent(MenuTarget.self, forKey: .menu) { + self = .menu(target) + return + } + if let target = try container.decodeIfPresent(WaitTarget.self, forKey: .waitFor) { + self = .waitFor(target) + return + } + if let target = try container.decodeIfPresent(SizeTarget.self, forKey: .resize) { + self = .resize(target) + return + } + if let target = try container.decodeIfPresent(DragTarget.self, forKey: .drag) { + self = .drag(target) + return + } + + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: + "expected one of select, press, click, perform, type, menu, wait_for, " + + "resize, drag" + ) + ) + } +} + +/// What a step did. +struct StepResult: Encodable, Equatable { + /// The step that ran, named as it was written. + let step: String + + let identifier: String + + /// The role of the element the step acted on. + /// + /// Not always the identified element: `select` climbs to the ancestor that + /// owns selection, and reporting the role it reached is how a restructuring of + /// the view surfaces as a changed role rather than as a puzzling failure. + let role: String + + /// Whether the intended change was observed after the step ran. + /// + /// A write can succeed and change nothing, so this is read back from the + /// element rather than inferred from the API's status. + /// + /// Absent for a step with nothing to read back. Pressing a button runs + /// arbitrary code in the target and has no attribute that says it worked, so + /// reporting `true` there would be claiming more than was checked. + let confirmed: Bool? + + /// Where a click was aimed, in screen coordinates. + /// + /// Only `click` reports this. A click is the one step whose outcome depends on + /// a number the caller cannot otherwise see, and "it clicked the wrong thing" + /// is unanswerable without knowing where it clicked. + let point: String? + + /// Whether an edit was committed through the element's confirm action. + /// + /// Only `type` reports this. `false` means the field took the text but + /// advertises no `AXConfirm`, so whether the application noticed depends on it + /// watching the value directly — worth knowing, because the text being in the + /// field and the application having seen it are different facts. + let committed: Bool? + + /// The size the element ended at, as `WIDTHxHEIGHT` in points. + /// + /// Only `resize` reports this. A window clamps a size to its own limits, so + /// what was asked for and what happened are different facts and the second is + /// the one worth reading. + let size: String? + + /// How many moves a drag posted between pressing and releasing. + /// + /// Only `drag` reports this. It is what separates a gesture from a jump, and + /// a caller asking why a view did not react during one wants to know how many + /// chances it had. + let moves: Int? + + init( + step: String, + identifier: String, + role: String, + confirmed: Bool? = nil, + committed: Bool? = nil, + point: String? = nil, + size: String? = nil, + moves: Int? = nil + ) { + self.step = step + self.identifier = identifier + self.role = role + self.confirmed = confirmed + self.committed = committed + self.point = point + self.size = size + self.moves = moves + } +} + +/// Runs a single step against a running application. +enum Act { + /// How far `select` looks above the identified element for one that accepts + /// selection. + /// + /// The known chain is two levels, from the identified element through the cell + /// to the row. The cap is above that so an extra wrapper does not break the + /// step, and low enough that a miss fails rather than selecting the window. + private static let maxAncestors = 4 + + /// Resolve the step's target and act on it. + static func run(_ step: Step, pid: pid_t) throws(DriveError) -> StepResult { + guard ProcessTable.record(for: pid) != nil else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + + guard AXIsProcessTrusted() else { + throw DriveError( + kind: .notPermitted, + message: "not trusted to read another application's accessibility tree", + hint: DriveError.accessibilityHint + ) + } + + return try run(step, in: AXElement.application(pid: pid), poster: SystemEventPoster()) + } + + /// Run a step against an already-resolved root. + /// + /// Split from ``run(_:pid:)`` so the part with the logic in it can be exercised + /// against a tree that is not a running application. `poster` is separate for + /// the same reason: a click is aimed using the tree but delivered outside it. + /// `activation` is how long a menu step waits for the application to come + /// forward and for the item to enable, and is a parameter so a test of either + /// wait does not have to sit through the real one. + static func run<E: Element>( + _ step: Step, + in root: E, + poster: any EventPoster = SystemEventPoster(), + activation: Duration = activationTimeout + ) throws(DriveError) -> StepResult { + switch step { + case .select(let target): + return try select(target, in: root) + + case .waitFor(let target): + return try waitFor(target, in: root) + + case .press(let target): + return try press(target, in: root) + + case .perform(let target): + return try perform(target.action, on: target.identifier, in: root, step: "perform") + + case .type(let target): + return try type(target, in: root) + + case .menu(let target): + return try menu(target, in: root, within: activation) + + case .click(let target): + return try click(target, in: root, poster: poster) + + case .resize(let target): + return try resize(target, in: root) + + case .drag(let target): + return try drag(target, in: root, poster: poster, activation: activation) + } + } + + /// How many moves a drag posts when it does not say. + private static let defaultDragSteps = 24 + + /// How long a drag pauses between moves when it does not say. + private static let defaultDragPause = Duration.milliseconds(8) + + /// Drag the pointer from one point on an element to another. + private static func drag<E: Element>( + _ target: Step.DragTarget, + in root: E, + poster: any EventPoster, + activation: Duration + ) throws(DriveError) -> StepResult { + let path = try find(target.identifier, from: root) + guard let element = path.last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(target.identifier)", + hint: nil + ) + } + + guard + let origin = element.point(kAXPositionAttribute), + let size = element.size(kAXSizeAttribute) + else { + throw DriveError( + kind: .notClickable, + message: "\(target.identifier) reports no frame to drag across", + hint: "an element with no position or size cannot be aimed at" + ) + } + + let steps = max(target.steps ?? defaultDragSteps, 1) + let pause = target.pauseMs.map { Duration.milliseconds($0) } ?? defaultDragPause + + let start = point(target.from, in: origin, size) + let end = point(target.to, in: origin, size) + let route = (0...steps).map { step in + let progress = Double(step) / Double(steps) + return CGPoint( + x: start.x + (end.x - start.x) * progress, + y: start.y + (end.y - start.y) * progress + ) + } + + // Activated, and then raised, and both are needed. + // + // `AXRaise` orders a window forward *within its own application*. Global + // ordering between applications follows activation, so raising a + // background app's window leaves it under the active app's windows: the + // gesture lands on whatever is on top at those coordinates, which is + // whatever the person at the keyboard is using. Measured, not assumed — a + // drag posted without this was received by the frontmost terminal. + // + // The cost is that a gesture takes focus. Nothing here can give it back: + // this process handles one step and exits, so the restore belongs to + // whatever drives the whole list. + activate(root, within: activation) + raiseWindow(in: path) + + guard poster.drag(through: route, pausing: pause) else { + throw DriveError( + kind: .actionFailed, + message: + "could not post a drag from \(start.x),\(start.y) to \(end.x),\(end.y)", + hint: nil + ) + } + + return StepResult( + step: "drag", + identifier: target.identifier, + role: element.read([kAXRoleAttribute]).text[0] ?? "<none>", + point: "\(start.x),\(start.y) -> \(end.x),\(end.y)", + moves: route.count - 1 + ) + } + + /// One fractional offset as a screen coordinate inside a frame. + private static func point( + _ offset: Step.Offset, in origin: CGPoint, _ size: CGSize + ) + -> CGPoint + { + CGPoint( + x: origin.x + size.width * offset.dx, + y: origin.y + size.height * offset.dy + ) + } + + /// Ask the identified element to take a new size. + /// + /// The size it ends at is read back and reported rather than assumed: a window + /// clamps to its own minimum and maximum, so asking for something outside those + /// succeeds and lands somewhere else. `confirmed` says whether it landed on + /// what was asked for. + private static func resize<E: Element>( + _ target: Step.SizeTarget, in root: E + ) throws(DriveError) -> StepResult { + let path = try find(target.identifier, from: root) + guard let element = path.last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(target.identifier)", + hint: "list what is addressable with: jpdrive tree --identifier <prefix>" + ) + } + + let role = element.read([kAXRoleAttribute]).text[0] ?? "" + + guard element.isSettable(kAXSizeAttribute) else { + throw DriveError( + kind: .notEditable, + message: "\(target.identifier) does not accept a write to AXSize", + hint: "a window does; most elements inside one do not" + ) + } + + let wanted = CGSize(width: target.width, height: target.height) + let status = element.setSize(kAXSizeAttribute, wanted) + guard status == .success else { + throw DriveError( + kind: .writeFailed, + message: "writing AXSize on \(target.identifier) failed: \(status.name)", + hint: nil + ) + } + + let reached = element.size(kAXSizeAttribute) + + return StepResult( + step: "resize", + identifier: target.identifier, + role: role, + confirmed: reached == wanted, + size: reached.map { "\(Int($0.width))x\(Int($0.height))" } + ) + } + + /// Click where the identified element says a click belongs. + /// + /// The last resort among the steps, and the only one whose effect is not + /// addressed to the element: the event goes to whatever occupies that screen + /// coordinate. Prefer `select` for rows and `press` for controls, both of which + /// reach their target regardless of what is on top of it or whether it is + /// scrolled into view. + private static func click<E: Element>( + _ target: Step.Target, + in root: E, + poster: any EventPoster + ) throws(DriveError) -> StepResult { + let path = try find(target.identifier, from: root) + guard let element = path.last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(target.identifier)", + hint: nil + ) + } + + guard let point = element.point(AXElement.activationPoint) else { + throw DriveError( + kind: .notClickable, + message: "\(target.identifier) reports no AXActivationPoint", + hint: + "an element with no place to be clicked is usually one that wants `select` " + + "or `press` instead" + ) + } + + // Raised first, because the click lands on whatever is at that coordinate + // rather than on the element that named it. A window behind another one + // would otherwise have its click swallowed by the window in front. + raiseWindow(in: path) + + guard poster.click(at: point) else { + throw DriveError( + kind: .actionFailed, + message: "could not post a click at \(point.x),\(point.y)", + hint: nil + ) + } + + return StepResult( + step: "click", + identifier: target.identifier, + role: element.read([kAXRoleAttribute]).text[0] ?? "<none>", + point: "\(point.x),\(point.y)" + ) + } + + /// Bring the application forward, ignoring a refusal. + /// + /// Best effort, unlike ``front(_:within:)``, which fails a menu step that + /// cannot activate: there the activation *is* the step, because AppKit + /// disables every item acting on the front window until the application is + /// frontmost. A pointer gesture only needs to be on top of the z-order, and a + /// tree that is not a running application — a test's — has nothing to + /// activate and a gesture against it is still worth posting. + private static func activate<E: Element>(_ root: E, within timeout: Duration) { + guard root.flag(kAXFrontmostAttribute) != true else { return } + guard root.setFlag(kAXFrontmostAttribute, true) == .success else { return } + + _ = poll(untilTrue: { root.flag(kAXFrontmostAttribute) == true }, within: timeout) + } + + /// Bring the window holding the addressed element to the front, if it has one. + /// + /// Found along the path the search descended, for the same reason the selection + /// owner is: the identified element does not report a parent to climb from. + private static func raiseWindow<E: Element>(in path: [E]) { + for element in path where element.read([kAXRoleAttribute]).text[0] == kAXWindowRole { + _ = element.perform(kAXRaiseAction) + return + } + } + + /// Press the identified element. + private static func press<E: Element>( + _ target: Step.Target, in root: E + ) throws(DriveError) + -> StepResult + { + return try perform(kAXPressAction, on: target.identifier, in: root, step: "press") + } + + /// Perform `action` on the element with `identifier`. + /// + /// `step` names the result, so `press` reports itself rather than the general + /// mechanism it is a shorthand for. + private static func perform<E: Element>( + _ action: String, + on identifier: String, + in root: E, + step: String + ) throws(DriveError) -> StepResult { + let path = try find(identifier, from: root) + guard let element = path.last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(identifier)", + hint: nil + ) + } + + // Checked before performing, so the error can name what the element does + // accept. Performing an unsupported action answers `action_unsupported` + // with nothing to act on. + let actions = element.actions + guard actions.contains(action) else { + throw DriveError( + kind: .actionUnsupported, + message: "\(identifier) does not accept \(action)", + hint: actions.isEmpty + ? "it advertises no actions at all; a list row is activated with `select`" + : "it accepts: \(actions.joined(separator: ", "))" + ) + } + + let status = element.perform(action) + guard status == .success else { + throw DriveError( + kind: .actionFailed, + message: "performing \(action) on \(identifier) answered \(status.name)", + hint: nil + ) + } + + return StepResult( + step: step, + identifier: identifier, + role: element.read([kAXRoleAttribute]).text[0] ?? "<none>", + confirmed: nil + ) + } + + /// Put text into the identified field. + private static func type<E: Element>( + _ target: Step.TypeTarget, in root: E + ) throws(DriveError) + -> StepResult + { + let path = try find(target.identifier, from: root) + guard let element = path.last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(target.identifier)", + hint: nil + ) + } + + guard element.isSettable(kAXValueAttribute) else { + throw DriveError( + kind: .notEditable, + message: "\(target.identifier) does not accept a write to AXValue", + hint: "a static label and a disabled field both look like this; check with " + + "`jpdrive dump --settable`" + ) + } + + let status = element.setText(kAXValueAttribute, target.text) + guard status == .success else { + throw DriveError( + kind: .writeFailed, + message: "writing AXValue to \(target.identifier) answered \(status.name)", + hint: nil + ) + } + + let committed = try confirm(element, identifier: target.identifier) + + // `confirmed` says the field holds the text, and nothing more. Whether the + // application reacted is the caller's assertion to make, against whatever + // the typing was supposed to change. + let after = element.read([kAXValueAttribute, kAXRoleAttribute]) + + return StepResult( + step: "type", + identifier: target.identifier, + role: after.text[1] ?? "<none>", + confirmed: after.text[0] == target.text, + committed: committed + ) + } + + /// Commit an edit, if the element offers a way to. + /// + /// Answers whether it did. An element with no confirm action is not a failure: + /// some fields publish every change as it happens and need nothing further. + private static func confirm<E: Element>( + _ element: E, identifier: String + ) throws(DriveError) -> Bool { + guard element.actions.contains(kAXConfirmAction) else { return false } + + let status = element.perform(kAXConfirmAction) + guard status == .success else { + throw DriveError( + kind: .actionFailed, + message: "confirming \(identifier) answered \(status.name)", + hint: + "the text was written but not committed, so the application has not seen it" + ) + } + + return true + } + + /// Attributes read while walking a menu path. + private static let menuBatch = [ + kAXRoleAttribute, + AXElement.attributedDescription, + kAXDescriptionAttribute, + kAXTitleAttribute, + ] + + /// How long to wait for the application to come forward, and for the item + /// addressed through it to be enabled. + static let activationTimeout = Duration.milliseconds(2000) + + /// Press the menu item at the end of a titled path. + private static func menu<E: Element>( + _ target: Step.MenuTarget, in root: E, within timeout: Duration + ) throws(DriveError) + -> StepResult + { + guard !target.path.isEmpty else { + throw DriveError( + kind: .badUsage, + message: "a menu step needs a path, such as [\"File\", \"Close\"]", + hint: nil + ) + } + + let start: E + let origin: String + + if let owner = target.under { + // A menu already on screen. No activation: showing it required the + // application to be active, and asking again would be a no-op at best. + start = try shownMenu(of: owner, in: root) + origin = "'\(owner)' is showing a menu that" + } else { + // The one step that takes focus from whatever had it. AppKit disables + // every menu item that acts on the front window or on the responder + // chain while the application is in the background, which is most of + // the menu bar: without this, a path resolves to an item that cannot + // be pressed. + try front(root, within: timeout) + + guard let bar = root.elements(kAXMenuBarAttribute).first else { + throw DriveError( + kind: .notFound, + message: "the application reports no menu bar", + hint: "an agent or accessory application has none" + ) + } + start = bar + origin = "the menu bar" + } + + var current = start + var reached: [String] = [] + + for title in target.path { + guard let next = child(titled: title, of: current) else { + throw DriveError( + kind: .notFound, + message: reached.isEmpty + ? "\(origin) holds no item titled '\(title)'" + : "'\(reached.joined(separator: " > "))' holds no item titled '\(title)'", + hint: "it holds: \(titles(of: current).joined(separator: ", "))" + ) + } + current = next + reached.append(title) + } + + let path = target.path.joined(separator: " > ") + try waitUntilEnabled(current, named: path, within: timeout) + + let actions = current.actions + guard actions.contains(kAXPressAction) else { + throw DriveError( + kind: .actionUnsupported, + message: "'\(path)' does not accept AXPress", + hint: actions.isEmpty + ? "the path names a submenu rather than an item; name the item inside it" + : "it accepts: \(actions.joined(separator: ", "))" + ) + } + + let status = current.perform(kAXPressAction) + guard status == .success else { + throw DriveError( + kind: .actionFailed, + message: "pressing '\(path)' answered \(status.name)", + hint: nil + ) + } + + return StepResult( + step: "menu", + identifier: path, + role: current.read([kAXRoleAttribute]).text[0] ?? "<none>", + confirmed: nil + ) + } + + /// The menu an element is currently displaying. + /// + /// A shown menu hangs off the element that opened it, after that element's + /// own children, which is why a capped or filtered read passes straight over + /// it. + private static func shownMenu<E: Element>( + of identifier: String, in root: E + ) throws(DriveError) -> E { + let path = try find(identifier, from: root) + guard let owner = path.last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(identifier)", + hint: nil + ) + } + + let children = owner.read([kAXRoleAttribute]).children + for child in children where child.read([kAXRoleAttribute]).text[0] == kAXMenuRole { + return child + } + + throw DriveError( + kind: .notFound, + message: "\(identifier) is not showing a menu", + hint: """ + open one first, in an earlier step: \ + {"perform": {"identifier": "\(identifier)", "action": "AXShowMenu"}} + """ + ) + } + + /// Bring the application forward, and wait until it reports that it is. + /// + /// Writing `AXFrontmost` is a request. The window server grants it a moment + /// later, and the menu validation that depends on it later still. + private static func front<E: Element>( + _ root: E, within timeout: Duration + ) throws(DriveError) { + guard root.flag(kAXFrontmostAttribute) != true else { return } + + let status = root.setFlag(kAXFrontmostAttribute, true) + guard status == .success else { + throw DriveError( + kind: .writeFailed, + message: "bringing the application forward answered \(status.name)", + hint: "a menu item that acts on the front window is disabled until it is" + ) + } + + guard poll(untilTrue: { root.flag(kAXFrontmostAttribute) == true }, within: timeout) + else { + throw DriveError( + kind: .timeout, + message: + "the application did not come forward within \(timeout.milliseconds)ms", + hint: "another application may be holding focus with a modal panel" + ) + } + } + + /// Wait for an item to stop reporting itself disabled. + /// + /// An element that reports no `AXEnabled` at all is not disabled: plenty + /// carry no such attribute, and treating its absence as a refusal would + /// reject every one of them. + private static func waitUntilEnabled<E: Element>( + _ item: E, named path: String, within timeout: Duration + ) throws(DriveError) { + if poll(untilTrue: { item.flag(kAXEnabledAttribute) != false }, within: timeout) { + return + } + + throw DriveError( + kind: .disabled, + message: "'\(path)' is disabled", + hint: + "an item acting on a selection is disabled while nothing is selected, and one " + + "acting on the front window while no window has focus" + ) + } + + /// Poll `condition` until it holds, or `timeout` elapses. + private static func poll(untilTrue condition: () -> Bool, within timeout: Duration) -> Bool + { + let clock = ContinuousClock() + let started = clock.now + + while true { + if condition() { return true } + guard clock.now - started < timeout else { return false } + Thread.sleep(forTimeInterval: defaultInterval.seconds) + } + } + + /// The child of `parent` whose title is `title`. + /// + /// Descends through `AXMenu`, which carries no title of its own: a bar item's + /// items live inside one, so a path names `["File", "Close"]` rather than + /// spelling out the container between them. + private static func child<E: Element>(titled title: String, of parent: E) -> E? { + for child in parent.read([]).children { + let text = child.read(menuBatch).text + + if text[1] ?? text[2] ?? text[3] == title { + return child + } + + guard text[0] == "AXMenu", let found = self.child(titled: title, of: child) else { + continue + } + return found + } + + return nil + } + + /// The titles a level offers, for saying what a path could have named instead. + private static func titles<E: Element>(of parent: E) -> [String] { + var found: [String] = [] + + for child in parent.read([]).children { + let text = child.read(menuBatch).text + + if let title = text[1] ?? text[2] ?? text[3], !title.isEmpty { + found.append(title) + continue + } + + // An untitled `AXMenu` is the container a path skips, so what it holds + // is what this level effectively offers. + guard text[0] == "AXMenu" else { continue } + found.append(contentsOf: titles(of: child)) + } + + return found + } + + /// Select the row that owns the identified element. + private static func select<E: Element>( + _ target: Step.Target, in root: E + ) throws(DriveError) + -> StepResult + { + let path = try find(target.identifier, from: root) + + guard let owner = selectionOwner(in: path) else { + throw DriveError( + kind: .notSelectable, + message: + "neither \(target.identifier) nor its \(maxAncestors) nearest ancestors accept " + + "a write to AXSelected", + hint: "check what the element reports with: jpdrive dump --settable" + ) + } + + let status = owner.setFlag(kAXSelectedAttribute, true) + guard status == .success else { + throw DriveError( + kind: .writeFailed, + message: "writing AXSelected to \(target.identifier) answered \(status.name)", + hint: nil + ) + } + + return StepResult( + step: "select", + identifier: target.identifier, + role: owner.read([kAXRoleAttribute]).text[0] ?? "<none>", + confirmed: owner.flag(kAXSelectedAttribute) ?? false + ) + } + + /// Default time to keep polling for an element to appear. + private static let defaultTimeout = Duration.milliseconds(5000) + + /// Default pause between polling attempts. + private static let defaultInterval = Duration.milliseconds(100) + + /// Wait until an element with the target's identifier exists. + /// + /// Returns as soon as it is found, including on the first attempt when it was + /// already there. + private static func waitFor<E: Element>( + _ target: Step.WaitTarget, in root: E + ) throws(DriveError) + -> StepResult + { + // Resolved once, before the loop. This is the expensive search, and paying + // it on every attempt is what makes an unscoped wait useless. + let scope: E + if let under = target.under { + guard let container = try find(under, from: root).last else { + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(under) to wait inside", + hint: "`under` names a container that must already exist" + ) + } + scope = container + } else { + scope = root + } + + let timeout = target.timeoutMs.map { Duration.milliseconds($0) } ?? defaultTimeout + let interval = target.intervalMs.map { Duration.milliseconds($0) } ?? defaultInterval + + let clock = ContinuousClock() + let started = clock.now + var attempts = 0 + + while true { + attempts += 1 + + if let path = try? find(target.identifier, from: scope), let found = path.last { + return StepResult( + step: "wait_for", + identifier: target.identifier, + role: found.read([kAXRoleAttribute]).text[0] ?? "<none>", + confirmed: true + ) + } + + guard clock.now - started < timeout else { break } + Thread.sleep(forTimeInterval: interval.seconds) + } + + let elapsed = clock.now - started + throw DriveError( + kind: .timeout, + message: + "\(target.identifier) did not appear within \(timeout.milliseconds)ms " + + "(\(attempts) attempts over \(elapsed.milliseconds)ms)", + hint: attempts == 1 + ? "one attempt exhausted the timeout; scope the search with `under`" + : nil + ) + } + + /// The nearest element at or above the end of `path` that accepts a write to + /// `AXSelected`. + /// + /// Walks the chain the search descended rather than reading `AXParent`. The + /// identified element is a SwiftUI leaf that does not report a parent, so + /// climbing from it arrives nowhere, while the chain that reached it is known + /// for free and is not subject to that. + private static func selectionOwner<E: Element>(in path: [E]) -> E? { + for element in path.suffix(maxAncestors + 1).reversed() + where element.isSettable(kAXSelectedAttribute) { + return element + } + return nil + } + + /// What a search reads at each element. + /// + /// Children arrive alongside, so the identifier is all the search asks for. + private static let searchBatch = [kAXIdentifierAttribute] + + /// Find the element whose identifier is exactly `identifier`, and the chain of + /// elements that reached it. + /// + /// The path comes back rather than the element alone because acting on an + /// element often means acting on one of its ancestors, and this tree cannot + /// reliably be walked upwards. + /// + /// Depth-first with an early exit, reading only what the search needs. Reading + /// every attribute of each element on the way past would make a step against + /// this app's few thousand elements cost seconds. + private static func find<E: Element>( + _ identifier: String, from root: E + ) throws(DriveError) + -> [E] + { + var stack = [[root]] + + while let path = stack.popLast() { + guard let element = path.last else { continue } + let reading = element.read(searchBatch) + + if reading.text[0] == identifier { + return path + } + + // Reversed, so a depth-first walk visits siblings in the order the + // application reports them. + for child in reading.children.reversed() { + stack.append(path + [child]) + } + } + + throw DriveError( + kind: .identifierNotFound, + message: "no element has the identifier \(identifier)", + hint: "list what is addressable with: jpdrive tree --identifier <prefix>" + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Ambient.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Ambient.swift new file mode 100644 index 000000000..0ad381f26 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Ambient.swift @@ -0,0 +1,80 @@ +import AppKit +import Foundation + +/// The state a driven run borrows from whoever is at the keyboard. +/// +/// Which application is in front, and where the pointer is. Neither belongs to +/// the app under test: a synthesized gesture has to take both — mouse events go +/// to whatever is on top at a coordinate, and the ordering between applications +/// follows activation — and a run that takes them owes them back. +/// +/// Deliberately not window geometry. A step that resizes a window did the thing +/// it was asked to do, and putting the window back would undo the effect under +/// test. What a run borrows is restored; what it was told to change is not. +/// +/// Read and written separately rather than as one capture-and-restore pair, so a +/// caller composes what it needs and decides for itself when a restore is owed. +enum Ambient { + /// The bundle identifier of the frontmost application. + /// + /// `nil` when there is none, or when it has no identifier — a process + /// launched without a bundle has neither. + static func frontmost() -> FrontmostReport { + FrontmostReport(bundleID: NSWorkspace.shared.frontmostApplication?.bundleIdentifier) + } + + /// Bring the application with `bundleID` back to the front. + /// + /// Through `NSWorkspace`, which asks the application to activate itself, so + /// this needs no permission beyond launching one. Answers whether an + /// application with that identifier was found to ask. + static func activate(bundleID: String) -> FrontmostReport { + guard + let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID) + .first + else { + return FrontmostReport(bundleID: nil) + } + + app.activate() + return FrontmostReport(bundleID: bundleID) + } + + /// Where the pointer is, in the coordinates a synthesized event uses. + /// + /// `NSEvent.mouseLocation` is bottom-left origin and screen coordinates are + /// top-left, so the y is flipped here rather than at each call site. The + /// height flipped against is the *main* screen's, which is what the window + /// server measures global coordinates from. + static func pointer() -> PointerReport { + let location = NSEvent.mouseLocation + let height = NSScreen.screens.first?.frame.height ?? 0 + + return PointerReport(x: location.x, y: height - location.y) + } + + /// Put the pointer back at `point`. + /// + /// Warped rather than moved: `CGWarpMouseCursorPosition` relocates the cursor + /// without synthesizing motion, so nothing under it takes a hover, and no + /// application sees a gesture it has to interpret. + static func movePointer(to point: CGPoint) -> PointerReport { + CGWarpMouseCursorPosition(point) + return PointerReport(x: point.x, y: point.y) + } +} + +/// Which application is in front. +struct FrontmostReport: Encodable, Equatable { + let bundleID: String? + + private enum CodingKeys: String, CodingKey { + case bundleID = "bundle_id" + } +} + +/// Where the pointer is, in top-left-origin screen coordinates. +struct PointerReport: Encodable, Equatable { + let x: CGFloat + let y: CGFloat +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Arguments.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Arguments.swift new file mode 100644 index 000000000..64fdbcfb0 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Arguments.swift @@ -0,0 +1,425 @@ +import Foundation + +/// A parsed command line. +enum Command { + /// Report whether this process may read another app's accessibility tree. + case doctor(pid: pid_t?) + + /// Print the elements and attributes under an application. + case dump(DumpOptions) + + /// Report the elements under an application, identified and described. + case tree(TreeOptions) + + /// List the application's windows. + case windows(pid: pid_t) + + /// Report the window-server identifiers of the application's windows. + case windowid(pid: pid_t) + + /// Report the application's menu bar. + case menu(pid: pid_t, options: TreeOptions) + + /// Do one thing to one element. + case act(step: Step, pid: pid_t) + + /// Report the colours along one row or column of a screenshot. + case pixels(PixelOptions) + + /// Report which application is in front, or put one there. + case frontmost(set: String?) + + /// Report where the pointer is, or put it somewhere. + case pointer(set: CGPoint?) +} + +/// What to walk, and how much of it. +struct DumpOptions { + let pid: pid_t + + /// How deep to recurse before reporting a node's children as elided. + let maxDepth: Int + + /// How many children to walk at each level, or `0` for all of them. + let maxSiblings: Int + + /// Whether to ask, per attribute, if it can be written. + let settable: Bool +} + +/// The command line the driver accepts. +/// +/// Hand-rolled rather than pulled from `swift-argument-parser`: the package has +/// no other dependency, and keeping it that way means the build needs no network +/// and no resolved manifest. +enum Arguments { + /// Usage text, embedded in every bad-usage error. + static let usage = """ + usage: jpdrive doctor [--pid <pid>] + jpdrive tree --pid <pid> [--identifier <prefix>] [--max-matches <n>] + [--frames] [--depth <n>] [--max-siblings <n>] + jpdrive windows --pid <pid> + jpdrive windowid --pid <pid> + jpdrive menu --pid <pid> [--depth <n>] [--max-siblings <n>] + jpdrive dump --pid <pid> [--depth <n>] [--max-siblings <n>] [--settable] + jpdrive act --pid <pid> --json '<step>' + a step is a single-key object, e.g. + {"resize":{"identifier":"w","width":1400,"height":900}} + jpdrive frontmost [--set <bundle-id>] + jpdrive pointer [--set <x>,<y>] + jpdrive pixels --image <path> --scan row|column --at <n> + [--from <n>] [--to <n>] + """ + + /// Depth cap for `dump` when `--depth` is not given. + /// + /// A SwiftUI window nests deeply: the wrapper groups between a `List` and its + /// rows are several levels on their own, so a cap low enough to be tidy hides + /// the elements worth seeing. + static let defaultDepth = 20 + + /// Sibling cap for `dump` when `--max-siblings` is not given. + /// + /// A thousand sidebar rows are a thousand copies of one shape, and walking + /// them all costs a round-trip per attribute per element. Five is enough to + /// see the shape and to tell a homogeneous list from a mixed one. + static let defaultSiblings = 5 + + /// Match budget for a filtered `tree` when `--max-matches` is not given. + /// + /// Identifiers sit on leaves, so a prefix search cannot prune on the way down + /// and an unbounded one reads every element in the application. Five answers + /// what a list looks like; looking up one known identifier wants `1`. + static let defaultMatches = 5 + + /// Parse `arguments`, which excludes the executable path. + static func parse(_ arguments: [String]) throws(DriveError) -> Command { + guard let subcommand = arguments.first else { + throw DriveError(kind: .badUsage, message: usage, hint: nil) + } + + let options = try options(arguments.dropFirst()) + + switch subcommand { + case "doctor": + return .doctor(pid: options.pid) + + case "dump": + guard let pid = options.pid else { + throw DriveError( + kind: .badUsage, + message: "dump needs --pid <pid>", + hint: usage + ) + } + return .dump( + DumpOptions( + pid: pid, + maxDepth: options.depth ?? defaultDepth, + maxSiblings: options.siblings ?? defaultSiblings, + settable: options.settable + ) + ) + + case "tree": + guard let pid = options.pid else { + throw DriveError( + kind: .badUsage, message: "tree needs --pid <pid>", hint: usage) + } + return .tree( + TreeOptions( + pid: pid, + identifierPrefix: options.identifier, + maxMatches: options.matches ?? defaultMatches, + maxDepth: options.depth ?? defaultDepth, + maxSiblings: options.siblings ?? defaultSiblings, + frames: options.frames + ) + ) + + case "frontmost": + return .frontmost(set: options.set) + + case "pointer": + guard let raw = options.set else { + return .pointer(set: nil) + } + + let parts = raw.split(separator: ",") + guard + parts.count == 2, + let x = Double(parts[0].trimmingCharacters(in: .whitespaces)), + let y = Double(parts[1].trimmingCharacters(in: .whitespaces)) + else { + throw DriveError( + kind: .badUsage, + message: "pointer --set takes <x>,<y>", + hint: usage + ) + } + return .pointer(set: CGPoint(x: x, y: y)) + + case "windows": + guard let pid = options.pid else { + throw DriveError( + kind: .badUsage, message: "windows needs --pid <pid>", hint: usage) + } + return .windows(pid: pid) + + case "windowid": + guard let pid = options.pid else { + throw DriveError( + kind: .badUsage, message: "windowid needs --pid <pid>", hint: usage) + } + return .windowid(pid: pid) + + case "menu": + guard let pid = options.pid else { + throw DriveError( + kind: .badUsage, message: "menu needs --pid <pid>", hint: usage) + } + return .menu( + pid: pid, + options: TreeOptions( + pid: pid, + identifierPrefix: options.identifier, + maxMatches: options.matches ?? defaultMatches, + maxDepth: options.depth ?? defaultDepth, + // A menu bar is a couple of hundred elements and every one of + // them is a thing you might press, so the default that keeps a + // thousand-row list readable would only hide half the verbs. + maxSiblings: options.siblings ?? 0, + frames: options.frames + ) + ) + + case "act": + guard let pid = options.pid else { + throw DriveError(kind: .badUsage, message: "act needs --pid <pid>", hint: usage) + } + guard let json = options.json else { + throw DriveError( + kind: .badUsage, message: "act needs --json '<step>'", hint: usage) + } + + let step: Step + do { + step = try JSONDecoder().decode(Step.self, from: Data(json.utf8)) + } catch { + throw DriveError( + kind: .badUsage, + message: "could not read the step: \(error)", + hint: #"a step is a single-key object, e.g. {"select":{"identifier":"…"}}"# + ) + } + + return .act(step: step, pid: pid) + + case "pixels": + guard let image = options.image else { + throw DriveError( + kind: .badUsage, message: "pixels needs --image <path>", hint: usage) + } + guard let scan = options.scan else { + throw DriveError( + kind: .badUsage, + message: "pixels needs --scan row or --scan column", + hint: usage + ) + } + guard let at = options.at else { + throw DriveError( + kind: .badUsage, message: "pixels needs --at <n>", hint: usage) + } + + return .pixels( + PixelOptions( + image: image, + axis: scan, + at: at, + from: options.from, + to: options.to + ) + ) + + default: + throw DriveError( + kind: .badUsage, + message: "unknown subcommand '\(subcommand)'", + hint: usage + ) + } + } + + /// Flags accepted by any subcommand, whether or not that subcommand reads + /// them. Keeping one parser means `--pid` behaves identically everywhere. + private struct Options { + var pid: pid_t? + var depth: Int? + var siblings: Int? + var matches: Int? + var settable = false + var identifier: String? + var json: String? + var frames = false + var image: String? + var scan: PixelOptions.Axis? + var at: Int? + var from: Int? + var to: Int? + var set: String? + } + + private static func options(_ arguments: ArraySlice<String>) throws(DriveError) -> Options { + var options = Options() + var rest = arguments.makeIterator() + + while let argument = rest.next() { + switch argument { + case "--pid": + guard let value = rest.next(), + let raw = Int(value), + let pid = pid_t(exactly: raw) + else { + throw DriveError( + kind: .badUsage, + message: "--pid takes an integer process id", + hint: """ + \(usage). `--pid $(pgrep -f JP.app)` expands to nothing \ + when the app is not running, which lands here rather \ + than reporting app_not_running + """ + ) + } + options.pid = pid + + case "--depth": + guard let value = rest.next(), let depth = Int(value), depth > 0 else { + throw DriveError( + kind: .badUsage, + message: "--depth takes a positive integer", + hint: usage + ) + } + options.depth = depth + + case "--max-siblings": + guard let value = rest.next(), let siblings = Int(value), siblings >= 0 else { + throw DriveError( + kind: .badUsage, + message: + "--max-siblings takes a non-negative integer, where 0 means all", + hint: usage + ) + } + options.siblings = siblings + + case "--settable": + options.settable = true + + case "--max-matches": + guard let value = rest.next(), let matches = Int(value), matches > 0 else { + throw DriveError( + kind: .badUsage, + message: "--max-matches takes a positive integer", + hint: usage + ) + } + options.matches = matches + + case "--frames": + options.frames = true + + case "--identifier": + guard let value = rest.next() else { + throw DriveError( + kind: .badUsage, + message: "--identifier takes a value", + hint: usage + ) + } + options.identifier = value + + case "--set": + guard let value = rest.next() else { + throw DriveError( + kind: .badUsage, + message: "--set takes a value", + hint: usage + ) + } + options.set = value + + case "--json": + guard let value = rest.next() else { + throw DriveError( + kind: .badUsage, + message: "--json takes a value", + hint: usage + ) + } + options.json = value + + case "--image": + guard let value = rest.next() else { + throw DriveError( + kind: .badUsage, + message: "--image takes a path", + hint: usage + ) + } + options.image = value + + case "--scan": + guard let value = rest.next(), let axis = PixelOptions.Axis(rawValue: value) + else { + throw DriveError( + kind: .badUsage, + message: "--scan takes `row` or `column`", + hint: usage + ) + } + options.scan = axis + + case "--at": + guard let value = rest.next(), let at = Int(value), at >= 0 else { + throw DriveError( + kind: .badUsage, + message: "--at takes a non-negative integer", + hint: usage + ) + } + options.at = at + + case "--from": + guard let value = rest.next(), let from = Int(value), from >= 0 else { + throw DriveError( + kind: .badUsage, + message: "--from takes a non-negative integer", + hint: usage + ) + } + options.from = from + + case "--to": + guard let value = rest.next(), let to = Int(value), to >= 0 else { + throw DriveError( + kind: .badUsage, + message: "--to takes a non-negative integer", + hint: usage + ) + } + options.to = to + + default: + throw DriveError( + kind: .badUsage, + message: "unknown argument '\(argument)'", + hint: usage + ) + } + } + + return options + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Doctor.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Doctor.swift new file mode 100644 index 000000000..b6a474a2b --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Doctor.swift @@ -0,0 +1,95 @@ +import ApplicationServices +import Foundation + +/// What `jpdrive doctor` observed. +struct DoctorReport: Encodable { + /// `AXIsProcessTrusted()` for this process. + let trusted: Bool + + /// This process and its ancestors, nearest first. One of these holds the + /// Accessibility grant when `trusted` is true. + let processes: [ProcessLink] + + /// A real read against a target app, present when `--pid` was given. + let probe: WindowProbe? +} + +/// The outcome of reading a target application's window list. +struct WindowProbe: Encodable { + let pid: pid_t + + /// The target's short command name. + let command: String + + /// How many windows were read, when the read succeeded. + let windowCount: Int? + + /// The accessibility error, when it did not. + let axError: String? +} + +/// Answers whether this process may read another application's accessibility +/// tree, and records the evidence for why. +/// +/// `AXIsProcessTrusted()` alone is not enough: it reports what TCC believes +/// about the responsible process, which is not always the process making the +/// call. So the report pairs the flag with a real `AXUIElementCopyAttributeValue` +/// against a live app, and with the ancestor chain the grant might be attributed +/// to. Apple documents neither the attribution algorithm nor its stability, so +/// this records observations rather than asserting a rule. +enum Doctor { + /// Run every probe and collect the results. + /// + /// Throws only when `pid` names a process that is not running. A refused + /// accessibility read is an observation the report carries, not a failure of + /// the diagnostic. + static func run(targetPid pid: pid_t?) throws(DriveError) -> DoctorReport { + let probe: WindowProbe? + if let pid { + guard let record = ProcessTable.record(for: pid) else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + probe = windowProbe(pid: pid, command: ProcessTable.name(of: record)) + } else { + probe = nil + } + + return DoctorReport( + trusted: AXIsProcessTrusted(), + processes: ProcessTable.ancestry(from: getpid()), + probe: probe + ) + } + + /// Read the target's window list, reporting the accessibility error instead + /// of the count when the read is refused. + /// + /// Uses the non-prompting trust path throughout: a spike that raises the + /// system's "grant access" dialog changes the state it is measuring. + private static func windowProbe(pid: pid_t, command: String) -> WindowProbe { + let app = AXUIElementCreateApplication(pid) + var value: CFTypeRef? + let status = AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &value) + + guard status == .success else { + return WindowProbe( + pid: pid, + command: command, + windowCount: nil, + axError: status.name + ) + } + + let windows = value as? [AXUIElement] + return WindowProbe( + pid: pid, + command: command, + windowCount: windows?.count ?? 0, + axError: nil + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/DriveError.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/DriveError.swift new file mode 100644 index 000000000..c08b6ebaf --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/DriveError.swift @@ -0,0 +1,81 @@ +import Foundation + +/// A failure reported as JSON on stdout, alongside a non-zero exit status. +/// +/// Every exit path produces either a result document or one of these, so a +/// caller never has to scrape prose off stderr to find out what happened. +struct DriveError: Error, Encodable { + /// Machine-readable discriminator. Callers switch on this; the message is + /// for humans and may be reworded freely. + enum Kind: String, Encodable { + /// The command line named an unknown subcommand or was missing a value. + case badUsage = "bad_usage" + + /// No process is running under the given pid. + case appNotRunning = "app_not_running" + + /// The accessibility API refused the request for want of a TCC grant. + case notPermitted = "not_permitted" + + /// No element carries the identifier the step addressed. + case identifierNotFound = "identifier_not_found" + + /// The addressed element and its nearest ancestors do not accept a write + /// to `AXSelected`. + case notSelectable = "not_selectable" + + /// An attribute write was refused by the accessibility API. + case writeFailed = "write_failed" + + /// The addressed element does not accept a write to its value. + case notEditable = "not_editable" + + /// The addressed element reports nowhere on screen to click. + case notClickable = "not_clickable" + + /// The addressed element does not accept the action the step performs. + case actionUnsupported = "action_unsupported" + + /// The addressed element is present but refuses to act while disabled. + case disabled = "disabled" + + /// An action was refused by the accessibility API. + case actionFailed = "action_failed" + + /// An element waited for did not appear in time. + case timeout = "timeout" + + /// The application reports no element of the requested kind. + case notFound = "not_found" + + /// The result could not be encoded as JSON. + case encodingFailed = "encoding_failed" + } + + let kind: Kind + + /// One sentence saying what went wrong. + let message: String + + /// What the operator can do about it, when there is something to do. + var hint: String? +} + +extension DriveError { + /// Names the System Settings pane that grants Accessibility. + /// + /// macOS attributes the grant to the responsible process, which for a + /// command-line tool is normally the terminal rather than the tool, so this + /// points at the terminal and not at `jpdrive`. + static let accessibilityHint = """ + grant Accessibility to the terminal application running this command, \ + under System Settings > Privacy & Security > Accessibility, then start \ + a new terminal session + """ +} + +/// Envelope that makes an error document distinguishable from a result document +/// by its top-level key alone. +struct ErrorDocument: Encodable { + let error: DriveError +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Driver.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Driver.swift new file mode 100644 index 000000000..d3e222961 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Driver.swift @@ -0,0 +1,68 @@ +import Foundation + +/// The driver's entry point. +/// +/// Everything below this is internal to the library, so the tests reach it with +/// `@testable import` and the executable target stays a single line. +public enum Driver { + /// Parse the process arguments, run the command, and exit. + /// + /// Writes one JSON document to stdout either way: a result, or an error with a + /// non-zero exit status. + public static func run() -> Never { + do throws(DriveError) { + try dispatch(Array(CommandLine.arguments.dropFirst())) + } catch { + Output.writeError(error) + exit(1) + } + + exit(0) + } + + /// Run one command and write its result. + static func dispatch(_ arguments: [String]) throws(DriveError) { + switch try Arguments.parse(arguments) { + case .doctor(let pid): + try Output.write(try Doctor.run(targetPid: pid)) + + case .dump(let options): + try Output.write(try Dump.walk(options)) + + case .tree(let options): + guard let tree = try Tree.read(options) else { + throw DriveError( + kind: .identifierNotFound, + message: + "no element's identifier begins with \(options.identifierPrefix ?? "")", + hint: "drop --identifier to see what the application reports" + ) + } + try Output.write(tree) + + case .windows(let pid): + try Output.write(try Windows.read(pid: pid)) + + case .windowid(let pid): + try Output.write(try WindowIDs.read(pid: pid)) + + case .menu(let pid, let options): + try Output.write(try Menu.read(pid: pid, options: options)) + + case .act(let step, let pid): + try Output.write(try Act.run(step, pid: pid)) + + case .pixels(let options): + try Output.write(try Pixels.read(options)) + + case .frontmost(let set): + let report = + if let set { Ambient.activate(bundleID: set) } else { Ambient.frontmost() } + try Output.write(report) + + case .pointer(let set): + let report = if let set { Ambient.movePointer(to: set) } else { Ambient.pointer() } + try Output.write(report) + } + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Dump.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Dump.swift new file mode 100644 index 000000000..9b9ae62dd --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Dump.swift @@ -0,0 +1,119 @@ +import ApplicationServices +import Foundation + +/// One accessibility attribute, as reported name and rendered value. +/// +/// A list of pairs rather than a dictionary, so attribute names reach the JSON +/// exactly as the accessibility API spells them. `JSONEncoder`'s snake-case key +/// strategy rewrites dictionary keys, which would turn `AXIdentifier` into +/// `ax_identifier` and make the dump a poor record of what the app reports. +struct DumpAttribute: Encodable { + let name: String + let value: String + + /// Whether the accessibility API reports this attribute as writable, when + /// settability was asked for. + /// + /// This decides how the driver changes state. Writing `AXSelected` on a row is + /// deterministic; synthesizing a click at a screen coordinate depends on the + /// window being frontmost and unobscured. + /// + /// Absent unless requested: answering it costs one round-trip per attribute, + /// which doubles the cost of a walk. + let settable: Bool? +} + +/// One element of an application's accessibility tree, with everything it reports. +struct DumpNode: Encodable { + /// `AXRole`, lifted out of the attributes because it is what a reader scans + /// for. + let role: String + + /// Every attribute the element reports, minus the two that only lead back into + /// the tree, sorted by name. + let attributes: [DumpAttribute] + + /// Actions the element accepts, such as `AXPress`. + let actions: [String] + + let children: [DumpNode] + + /// How many children were dropped to keep the walk bounded. + /// + /// Absent when every child was walked. A sidebar of a thousand conversations + /// repeats one row shape a thousand times, so the count is the useful part and + /// the repetition is not. + let elidedChildren: Int? +} + +/// Walks an application's accessibility tree and reports everything it finds. +/// +/// This is a design instrument. SwiftUI's mapping onto accessibility elements is +/// undocumented and not one-to-one, so decisions about how to address and act on +/// an element are made by reading a real dump rather than by predicting where a +/// `.accessibilityIdentifier` lands. +/// +/// Unfiltered by intent: every attribute of every element it visits, so nothing +/// that turns out to matter has been quietly dropped. [`Tree`](Tree) is the +/// filtered counterpart for everyday use. +enum Dump { + /// Walk the tree rooted at the application owning `options.pid`. + static func walk(_ options: DumpOptions) throws(DriveError) -> DumpNode { + guard ProcessTable.record(for: options.pid) != nil else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(options.pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + + // Checked up front rather than reported per element: without the grant + // every read fails, and a tree of identical refusals says less than one + // error naming the pane that fixes it. + guard AXIsProcessTrusted() else { + throw DriveError( + kind: .notPermitted, + message: "not trusted to read another application's accessibility tree", + hint: DriveError.accessibilityHint + ) + } + + return node(AXElement.application(pid: options.pid), depth: 0, options: options) + } + + /// Attributes that only lead back into the tree, and so are not recorded. + /// + /// `AXChildren` is what the walk recurses into, and `AXParent` points at the + /// element that just reported this one. + private static let structuralAttributes: Set<String> = [ + kAXChildrenAttribute, + kAXParentAttribute, + ] + + private static func node(_ element: AXElement, depth: Int, options: DumpOptions) -> DumpNode + { + let names = + element.names() + .filter { !structuralAttributes.contains($0) } + .sorted() + + let attributes = zip(names, element.values(names)).map { name, value in + DumpAttribute( + name: name, + value: value.map(AXElement.text) ?? "<null>", + settable: options.settable ? element.isSettable(name) : nil + ) + } + + let all = depth < options.maxDepth ? element.children : [] + let walked = options.maxSiblings > 0 ? Array(all.prefix(options.maxSiblings)) : all + + return DumpNode( + role: attributes.first { $0.name == kAXRoleAttribute }?.value ?? "<none>", + attributes: attributes, + actions: element.actions, + children: walked.map { node($0, depth: depth + 1, options: options) }, + elidedChildren: all.count > walked.count ? all.count - walked.count : nil + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Duration.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Duration.swift new file mode 100644 index 000000000..09ef2dbce --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Duration.swift @@ -0,0 +1,15 @@ +import Foundation + +extension Duration { + /// The duration in whole milliseconds, for reporting. + var milliseconds: Int { + let (seconds, attoseconds) = components + return Int(seconds) * 1000 + Int(attoseconds / 1_000_000_000_000_000) + } + + /// The duration in seconds, for the APIs that take a `TimeInterval`. + var seconds: TimeInterval { + let (seconds, attoseconds) = components + return TimeInterval(seconds) + TimeInterval(attoseconds) / 1e18 + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Element.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Element.swift new file mode 100644 index 000000000..dd2ef2a30 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Element.swift @@ -0,0 +1,186 @@ +import ApplicationServices + +extension Optional where Wrapped == String { + /// The value read as a boolean. + /// + /// The accessibility API renders `AXEnabled`, `AXMain` and their like as `"0"` + /// or `"1"`. Anything else, including an absent attribute, is neither true nor + /// false. + var axFlag: Bool? { + switch self { + case "0": return false + case "1": return true + default: return nil + } + } +} + +/// Posts synthesized input to the window server. +/// +/// Behind a protocol because posting is the one thing the driver does that is not +/// addressed to an element. A click goes to whatever occupies a screen +/// coordinate, which is global state and cannot be exercised against a fake tree +/// the way every other step can. +protocol EventPoster { + /// Click once at `point`, in screen coordinates. + /// + /// Answers whether the events could be built and posted, which is not whether + /// anything received them. + func click(at point: CGPoint) -> Bool + + /// Press at the first point of `path`, move through the rest, release at the + /// last. + /// + /// `pause` separates one move from the next. Without it the moves are posted + /// faster than the target can consume them and the window server delivers a + /// coalesced few, which is the opposite of what a drag is usually being + /// synthesized to exercise: what a view does *during* the gesture, frame by + /// frame. + /// + /// Answers whether every event could be built and posted. + func drag(through path: [CGPoint], pausing pause: Duration) -> Bool +} + +/// Posts through `CoreGraphics`. +struct SystemEventPoster: EventPoster { + func click(at point: CGPoint) -> Bool { + guard + let down = event(.leftMouseDown, at: point), + let up = event(.leftMouseUp, at: point) + else { + return false + } + + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) + return true + } + + func drag(through path: [CGPoint], pausing pause: Duration) -> Bool { + guard let first = path.first, let last = path.last else { return false } + guard let down = event(.leftMouseDown, at: first) else { return false } + + down.post(tap: .cghidEventTap) + + for point in path.dropFirst() { + guard let moved = event(.leftMouseDragged, at: point) else { + // Released wherever it got to rather than returned from. A drag + // abandoned with the button still down leaves the whole machine + // holding a mouse button nobody is pressing, which outlives this + // process and is not something a failed test should do to the + // person running it. + release(at: point) + return false + } + + moved.post(tap: .cghidEventTap) + Thread.sleep(forTimeInterval: pause.seconds) + } + + guard let up = event(.leftMouseUp, at: last) else { + release(at: last) + return false + } + + up.post(tap: .cghidEventTap) + return true + } + + /// Let the button go, on a path that could not be finished. + private func release(at point: CGPoint) { + event(.leftMouseUp, at: point)?.post(tap: .cghidEventTap) + } + + private func event(_ type: CGEventType, at point: CGPoint) -> CGEvent? { + CGEvent( + mouseEventSource: nil, + mouseType: type, + mouseCursorPosition: point, + mouseButton: .left + ) + } +} + +/// Attribute text and children, read together. +/// +/// The pair exists because reading them separately costs an extra round-trip per +/// element, and walking to a child is the most common read the driver makes. +struct Reading<E> { + /// One entry per requested name, positionally, `nil` where the element has no + /// value for that attribute. + let text: [String?] + + let children: [E] +} + +/// One element of an accessibility tree, as the driver's traversal needs it. +/// +/// The traversal is where the driver's logic lives: pruning a filtered walk, +/// spending a match budget, finding which ancestor of an identified element owns +/// selection. None of that is about the accessibility API, and all of it has been +/// wrong at least once. Behind this protocol it can be tested against a fake tree +/// instead of against a running application. +/// +/// Deliberately narrow. Everything here is something a walk actually does, so a +/// fake stays small enough to read at a glance and cannot drift far from the real +/// implementation. +protocol Element { + /// Read the named attributes and the element's children. + /// + /// Implementations batch: this is one round-trip in the real one. + func read(_ names: [String]) -> Reading<Self> + + /// Actions the element accepts, such as `AXPress`. + /// + /// Separate from ``read(_:)`` because it costs its own round-trip and most + /// elements a filtered walk passes through are discarded unread. + var actions: [String] { get } + + /// Whether `name` can be written on this element. + func isSettable(_ name: String) -> Bool + + /// Read a boolean attribute, `nil` when it is absent or not a boolean. + func flag(_ name: String) -> Bool? + + /// Write a boolean attribute, answering the accessibility API's own status. + /// + /// A successful write is not a successful change: the target can accept the + /// value and do nothing with it. Read it back to find out. + func setFlag(_ name: String, _ value: Bool) -> AXError + + /// Write a string attribute, answering the accessibility API's own status. + func setText(_ name: String, _ value: String) -> AXError + + /// Perform an action, answering the accessibility API's own status. + /// + /// What the action did is not observable from here. Pressing a button runs + /// arbitrary code in the target, and success means the press was delivered, + /// not that anything came of it. + func perform(_ action: String) -> AXError + + /// The point held in an attribute, in screen coordinates. + /// + /// `nil` when the attribute is absent or holds something else. Separate from + /// ``read(_:)`` because a caller aiming a click needs the numbers, not the + /// text they render as. + func point(_ name: String) -> CGPoint? + + /// The size held in an attribute, in points. + /// + /// `nil` when the attribute is absent or holds something else. + func size(_ name: String) -> CGSize? + + /// Write a size attribute, answering the accessibility API's own status. + /// + /// As with every other write here, success is not change: a window clamps a + /// size to its own minimum and maximum, so what it ends up at has to be read + /// back. + func setSize(_ name: String, _ value: CGSize) -> AXError + + /// The elements held in an attribute, such as `AXWindows` or `AXMenuBar`. + /// + /// Answers a single element as a one-element array, since the accessibility + /// API spells "the menu bar" and "the windows" the same way apart from the + /// plural. + func elements(_ name: String) -> [Self] +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Menu.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Menu.swift new file mode 100644 index 000000000..a1c595b9d --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Menu.swift @@ -0,0 +1,52 @@ +import ApplicationServices +import Foundation + +/// Reads an application's menu bar. +/// +/// Reported as a tree, because a menu is one: bar, then bar items, then menus, +/// then items. What makes it worth its own subcommand is the root — reaching the +/// menu bar from the application element takes an attribute that holds it, not a +/// walk through the window hierarchy. +/// +/// Menu items are pressed with `act press`; they advertise `AXPress` where a list +/// row does not. +enum Menu { + /// Read the menu bar of the application owning `pid`. + static func read(pid: pid_t, options: TreeOptions) throws(DriveError) -> TreeNode { + guard ProcessTable.record(for: pid) != nil else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + + guard AXIsProcessTrusted() else { + throw DriveError( + kind: .notPermitted, + message: "not trusted to read another application's accessibility tree", + hint: DriveError.accessibilityHint + ) + } + + let app = AXElement.application(pid: pid) + + guard let bar = app.elements(kAXMenuBarAttribute).first else { + throw DriveError( + kind: .notFound, + message: "the application reports no menu bar", + hint: "an agent or accessory application has none" + ) + } + + guard let tree = Tree.walk(from: bar, options: options) else { + throw DriveError( + kind: .notFound, + message: "the menu bar held nothing matching", + hint: nil + ) + } + + return tree + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Output.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Output.swift new file mode 100644 index 000000000..47ca031d5 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Output.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Writes the driver's JSON documents. +/// +/// Both results and errors go to stdout, so a caller reads one stream and +/// distinguishes the two by the top-level `error` key or by the exit status. +enum Output { + /// Encode `value` as pretty JSON on stdout, with a trailing newline. + static func write(_ value: some Encodable) throws(DriveError) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + + // Snake case on the wire, matching every other JSON payload JP produces. + encoder.keyEncodingStrategy = .convertToSnakeCase + + let data: Data + do { + data = try encoder.encode(value) + } catch { + throw DriveError( + kind: .encodingFailed, + message: "could not encode the result as JSON: \(error)", + hint: nil + ) + } + + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data("\n".utf8)) + } + + /// Write an error document. + /// + /// Falls back to hand-built JSON, so a caller still gets something parseable + /// in the case where even the error will not encode. + static func writeError(_ error: DriveError) { + do throws(DriveError) { + try write(ErrorDocument(error: error)) + } catch { + let message = error.message.replacingOccurrences(of: "\"", with: "'") + let json = #"{"error":{"kind":"encoding_failed","message":"\#(message)"}}"# + "\n" + FileHandle.standardOutput.write(Data(json.utf8)) + } + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Pixels.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Pixels.swift new file mode 100644 index 000000000..d9a0c8d8d --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Pixels.swift @@ -0,0 +1,252 @@ +import CoreGraphics +import Foundation +import ImageIO + +/// One stretch of identical pixels along a scanline. +struct PixelRun: Encodable, Equatable { + /// Where the run begins, in pixels along the scan. + let start: Int + + /// How many pixels it covers. + let count: Int + + /// The colour, `#RRGGBB` when opaque and `#RRGGBBAA` when it is not. + let color: String +} + +/// What one scan across an image found. +struct PixelReport: Encodable, Equatable { + /// The image's width in pixels, which on a retina display is twice its width + /// in points. + let width: Int + + /// The image's height in pixels. + let height: Int + + /// The colour space the values are reported in. + /// + /// Always sRGB. Stated anyway, because the numbers mean nothing without it: + /// the same screenshot read in the display's own profile and in sRGB gives two + /// different sets of values for the same pixels, and a light grey moves by + /// several steps between them. + /// + /// sRGB because that is the space colours are *written* in — a palette + /// constant, a value from a colour picker, a hex in a design note — so a + /// reading can be compared against the thing it was supposed to be. + let colorSpace: String + + /// Which way the scan ran: `row` or `column`. + let scan: String + + /// The row or column that was read, in pixels. + let at: Int + + /// The runs along it, in order, covering the scanned range without gaps. + let runs: [PixelRun] +} + +/// What to scan, and where. +struct PixelOptions { + /// Which way a scan runs. + enum Axis: String { + /// Left to right, across one row. + case row + + /// Top to bottom, down one column. + case column + } + + /// The PNG to read. + let image: String + + let axis: Axis + + /// The row or column to read, in pixels. + let at: Int + + /// Where along the scan to start, in pixels. The near edge when absent. + let from: Int? + + /// Where along the scan to stop, inclusive, in pixels. The far edge when + /// absent. + let to: Int? +} + +/// Reads the pixels of a screenshot. +/// +/// Answers the questions the accessibility tree cannot: what colour something is, +/// and how wide a drawn thing is. A hairline, a selection fill, a divider and a +/// row separator are all invisible to the tree, and all obvious in a scanline. +/// +/// Reads a file rather than capturing one. Capture already has a home +/// (`screencapture`, driven by `debug_app_screenshot`), and the only ways to +/// capture from inside this process are deprecated. It also makes this testable +/// against an image built by hand, with no window server and no grants. +enum Pixels { + /// Scan `options.image` and report the runs along the requested line. + static func read(_ options: PixelOptions) throws(DriveError) -> PixelReport { + let bitmap = try Bitmap(path: options.image) + let extent = options.axis == .row ? bitmap.width : bitmap.height + let across = options.axis == .row ? bitmap.height : bitmap.width + + guard options.at >= 0, options.at < across else { + throw DriveError( + kind: .notFound, + message: + "\(options.axis.rawValue) \(options.at) is outside the image, which is " + + "\(bitmap.width)x\(bitmap.height) pixels", + hint: "a row is indexed down from the top and a column across from the left" + ) + } + + let from = max(options.from ?? 0, 0) + let to = min(options.to ?? extent - 1, extent - 1) + + guard from <= to else { + throw DriveError( + kind: .badUsage, + message: "--from \(from) is past --to \(to)", + hint: "both are pixel offsets along the scan, and --to is inclusive" + ) + } + + let line = (from...to).map { along in + options.axis == .row + ? bitmap.pixel(x: along, y: options.at) + : bitmap.pixel(x: options.at, y: along) + } + + return PixelReport( + width: bitmap.width, + height: bitmap.height, + colorSpace: bitmap.colorSpace, + scan: options.axis.rawValue, + at: options.at, + runs: runs(of: line, startingAt: from) + ) + } + + /// Collapse `line` into runs of one colour, the first starting at `start`. + /// + /// The whole point of the output shape: a scan across a window is thousands of + /// pixels and a handful of colours, and the edges between them are the + /// measurements a reader is after. + static func runs(of line: [Pixel], startingAt start: Int) -> [PixelRun] { + var runs: [PixelRun] = [] + + for (offset, pixel) in line.enumerated() { + if let last = runs.last, last.color == pixel.hex { + runs[runs.count - 1] = PixelRun( + start: last.start, count: last.count + 1, color: last.color) + continue + } + + runs.append(PixelRun(start: start + offset, count: 1, color: pixel.hex)) + } + + return runs + } +} + +/// One pixel, as read out of an image. +struct Pixel: Equatable { + let red: UInt8 + let green: UInt8 + let blue: UInt8 + let alpha: UInt8 + + /// `#RRGGBB` when opaque, `#RRGGBBAA` when not. + /// + /// Alpha is left off the common case so the values read the way a colour + /// picker reports them, and included when it is not 255 because a translucent + /// pixel that printed as opaque would be a lie about what is on screen. + var hex: String { + let rgb = String(format: "#%02X%02X%02X", red, green, blue) + return alpha == 255 ? rgb : rgb + String(format: "%02X", alpha) + } +} + +/// An image's pixels, in the image's own colour space. +private struct Bitmap { + let width: Int + let height: Int + let colorSpace: String + + /// RGBA, row-major, four bytes per pixel and no row padding. + private let bytes: [UInt8] + + /// Decode the PNG at `path`. + init(path: String) throws(DriveError) { + guard + let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { + throw DriveError( + kind: .notFound, + message: "could not read an image at \(path)", + hint: "debug_app_screenshot writes one, and reports where it put it" + ) + } + + width = image.width + height = image.height + + // Converted rather than read raw. `screencapture` writes in the display's + // profile, which is often unnamed and never the space a palette was + // written in: a `#DBDBDB` divider comes back as `#D6D6D6` read that way, + // which looks like a bug in the app rather than a difference of space. + let target = CGColorSpace(name: CGColorSpace.sRGB) + + guard + let target, + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: target, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + throw DriveError( + kind: .notFound, + message: "could not open \(path) as an 8-bit RGBA image", + hint: nil + ) + } + + colorSpace = "sRGB" + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + + guard let data = context.data else { + throw DriveError( + kind: .notFound, + message: "the drawing context for \(path) reported no pixels", + hint: nil + ) + } + + bytes = [UInt8]( + UnsafeBufferPointer( + start: data.assumingMemoryBound(to: UInt8.self), + count: width * height * 4 + )) + } + + /// The pixel at `x`, `y`, counted from the top-left corner. + /// + /// The buffer runs in the same direction: a bitmap context's first row is the + /// top of what was drawn into it, so a screenshot's rows and this buffer's rows + /// are the same rows in the same order. + func pixel(x: Int, y: Int) -> Pixel { + let offset = (y * width + x) * 4 + + return Pixel( + red: bytes[offset], + green: bytes[offset + 1], + blue: bytes[offset + 2], + alpha: bytes[offset + 3] + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/ProcessTable.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/ProcessTable.swift new file mode 100644 index 000000000..9aed316e4 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/ProcessTable.swift @@ -0,0 +1,58 @@ +import Darwin +import Foundation + +/// One process in the chain from the driver up towards `launchd`. +struct ProcessLink: Encodable { + let pid: pid_t + + /// Short command name from the kernel process table. The kernel truncates it + /// to 16 bytes, so `Terminal` and `iTerm2` arrive whole but a long binary + /// name does not. + let command: String +} + +/// Process identity read from the kernel through `sysctl(KERN_PROC_PID)`. +/// +/// The spike needs the ancestor chain because TCC attributes a grant to the +/// responsible process, and the chain is the list of candidates for that role. +enum ProcessTable { + /// Depth limit for the ancestor walk. A shell-to-`launchd` chain is a + /// handful of processes; the limit only guards against a process table that + /// changes underneath the walk. + private static let maxDepth = 32 + + /// `pid` and its ancestors, nearest first, stopping below `launchd`. + static func ancestry(from pid: pid_t) -> [ProcessLink] { + var links: [ProcessLink] = [] + var current = pid + + while current > 1, links.count < maxDepth { + guard let record = record(for: current) else { break } + links.append(ProcessLink(pid: current, command: name(of: record))) + current = record.kp_eproc.e_ppid + } + + return links + } + + /// The kernel's record for `pid`, or `nil` when no such process is running. + static func record(for pid: pid_t) -> kinfo_proc? { + var selector: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + var record = kinfo_proc() + var size = MemoryLayout<kinfo_proc>.stride + + let result = sysctl(&selector, u_int(selector.count), &record, &size, nil, 0) + + // Querying a pid that no longer exists succeeds and writes nothing, so + // the written size is what separates a dead pid from a live one. + guard result == 0, size > 0 else { return nil } + return record + } + + /// The short command name held in a kernel record. + static func name(of record: kinfo_proc) -> String { + return withUnsafeBytes(of: record.kp_proc.p_comm) { bytes in + return String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self) + } + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Tree.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Tree.swift new file mode 100644 index 000000000..c9a6f4ad5 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Tree.swift @@ -0,0 +1,183 @@ +import ApplicationServices +import Foundation + +/// One element, as the driver reports it. +/// +/// An absent field means the element does not report that attribute. An +/// `AXUnknown` row carries a label and no value; a text field carries both. +struct TreeNode: Encodable, Equatable { + let role: String + let identifier: String? + let label: String? + let value: String? + let enabled: Bool? + let focused: Bool? + + /// The element's frame in screen coordinates, only when frames were asked for. + /// + /// Left out by default: coordinates change whenever a window moves or a list + /// scrolls, so including them turns every diff between two snapshots into + /// noise. + let frame: String? + + let actions: [String] + let children: [TreeNode] + + /// How many of this element's children are missing from ``children``. + /// + /// Every reason a child goes missing is counted the same, because they answer + /// one question: is there more here than I am looking at? The depth limit, the + /// per-level sibling cap, the match budget running out, and a filter discarding + /// a branch that held no match all leave the reader in the same position, and + /// the last two are the easiest to mistake for an element having no children at + /// all. + let elidedChildren: Int? +} + +/// What to walk, and what to keep. +struct TreeOptions { + let pid: pid_t + + /// Keep only elements whose identifier begins with this, along with the + /// ancestors that lead to them. `nil` keeps everything. + /// + /// A prefix rather than an exact match, because the useful question to ask of a + /// tree is "what is under `sidebar.`". Acting on an element is the opposite + /// case and matches exactly. + let identifierPrefix: String? + + /// How many matches to find before stopping. + /// + /// This is the bound that matters. Every identifier in this app's sidebar sits + /// on a leaf, so a prefix search cannot prune on the way down and an unbounded + /// one visits every element in the application. Stopping at a handful of + /// matches answers "what does the sidebar look like" for the cost of the first + /// handful rather than of all thousand. + /// + /// Set this to `1` when looking up one identifier already known, or the walk + /// continues past it looking for a second. + let maxMatches: Int + + let maxDepth: Int + let maxSiblings: Int + let frames: Bool +} + +/// Reads an application's accessibility tree into something a person can scan. +/// +/// Where [`Dump`](Dump) reports every attribute of every element for design work, +/// this reports the handful that identify and describe an element, and prunes +/// branches holding nothing that matched. +enum Tree { + /// Attributes read for every node, in one batch. Order matters, since values + /// come back positionally. + static let batch = [ + kAXRoleAttribute, + kAXIdentifierAttribute, + AXElement.attributedDescription, + kAXDescriptionAttribute, + kAXTitleAttribute, + kAXValueAttribute, + kAXEnabledAttribute, + kAXFocusedAttribute, + "AXFrame", + ] + + /// Walk the tree of the application owning `options.pid`. + /// + /// Returns `nil` when a prefix was given and nothing matched it. + static func read(_ options: TreeOptions) throws(DriveError) -> TreeNode? { + guard ProcessTable.record(for: options.pid) != nil else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(options.pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + + guard AXIsProcessTrusted() else { + throw DriveError( + kind: .notPermitted, + message: "not trusted to read another application's accessibility tree", + hint: DriveError.accessibilityHint + ) + } + + return walk(from: AXElement.application(pid: options.pid), options: options) + } + + /// Walk from `root`, spending a fresh match budget. + static func walk<E: Element>(from root: E, options: TreeOptions) -> TreeNode? { + // An unfiltered walk has no matches to count, so the budget only bounds a + // filtered one. + var remaining = options.identifierPrefix == nil ? Int.max : options.maxMatches + return node(root, depth: 0, options: options, remaining: &remaining) + } + + private static func node<E: Element>( + _ element: E, + depth: Int, + options: TreeOptions, + remaining: inout Int + ) -> TreeNode? { + let reading = element.read(batch) + let text = reading.text + + let identifier = text[1] + let matches = + options.identifierPrefix.map { identifier?.hasPrefix($0) ?? false } ?? false + if matches { + remaining -= 1 + } + + // Every child the element has, whether or not this walk descends into it. + // The count is what tells a reader there is more here; without it a node + // stopped at the depth limit is indistinguishable from a leaf. + let available = reading.children + let all = depth < options.maxDepth ? available : [] + + // The sibling cap is for reading an unfiltered tree, where every level is + // worth seeing but a thousand copies of one row are not. Under a filter the + // match budget does the bounding instead: a cap here would hide the eight + // hundredth row from a search that named it. + let capped = options.identifierPrefix == nil && options.maxSiblings > 0 + + var children: [TreeNode] = [] + var visited = 0 + + for child in all { + guard remaining > 0 else { break } + guard !capped || visited < options.maxSiblings else { break } + visited += 1 + + guard + let node = node( + child, depth: depth + 1, options: options, remaining: &remaining) + else { continue } + children.append(node) + } + + // A branch is kept when it matches, or when something under it does. The + // ancestors are what make a match locatable rather than a bare hit. + guard matches || !children.isEmpty || options.identifierPrefix == nil else { + return nil + } + + return TreeNode( + role: text[0] ?? "<none>", + identifier: identifier, + label: text[2] ?? text[3] ?? text[4], + value: text[5], + enabled: text[6].axFlag, + focused: text[7].axFlag, + frame: options.frames ? text[8] : nil, + // Read only for a node being kept. Actions cost their own round-trip and + // most elements a filtered walk passes through are discarded. + actions: element.actions, + children: children, + elidedChildren: available.count > children.count + ? available.count - children.count + : nil + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/WindowIDs.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/WindowIDs.swift new file mode 100644 index 000000000..063735580 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/WindowIDs.swift @@ -0,0 +1,133 @@ +import CoreGraphics +import Foundation + +/// A window the window server can be told to capture. +struct CaptureWindow: Encodable, Equatable { + /// The window server's identifier, in the form `screencapture -l` takes. + let id: CGWindowID + + /// The window's title, or `nil` when this process holds no Screen Recording + /// grant: the window server withholds other applications' titles until it + /// does. + let title: String? + + let width: Int + let height: Int +} + +/// What `jpdrive windowid` observed. +struct WindowIDReport: Encodable, Equatable { + /// Whether this process may read other applications' screen content. + /// + /// Enumerating windows needs no grant, so a report can list windows that + /// cannot be captured. A caller that acts on the list without reading this + /// gets a picture of the desktop where it expected a window. + let screenRecording: Bool + + /// The application's capturable windows, front to back. + let windows: [CaptureWindow] + + /// Windows the application has that are not on the active Space. + /// + /// Reported separately because the two look identical from the outside and + /// mean opposite things. A window on another desktop is absent from every + /// on-screen enumeration and from the accessibility tree, so an app that has + /// one and nothing else is indistinguishable from an app with no window at + /// all — except by asking for windows on every Space, which is this list. + let otherSpaces: [CaptureWindow] +} + +/// Resolves an application's window-server identifiers. +/// +/// Separate from `Windows`, which reads the accessibility tree: the two answer +/// different questions and neither identifier converts into the other. An +/// accessibility window has a title and a frame but no number the capture tools +/// accept, and a window-server window has that number but nothing structural. +enum WindowIDs { + /// The layer ordinary application windows sit on. + /// + /// Everything else the window server reports for an application is chrome — + /// tooltips, drag images, the shadow behind a menu — and capturing one of + /// those instead of the window is a silent wrong answer rather than a + /// failure. + static let normalLayer = 0 + + /// The capturable windows of the application owning `pid`. + static func read(pid: pid_t) throws(DriveError) -> WindowIDReport { + guard ProcessTable.record(for: pid) != nil else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + + let onScreen = + CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] ?? [] + + // Every Space, not just the active one. The difference between the two + // lists is what says a window exists somewhere the screen cannot show it. + let everywhere = + CGWindowListCopyWindowInfo( + [.excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] ?? [] + + let here = capturable(from: onScreen, pid: pid) + let all = capturable(from: everywhere, pid: pid) + let shown = Set(here.map(\.id)) + + // The preflight variant, never the requesting one: raising the system's + // permission dialog from a background tool leaves a prompt nobody is + // watching, in front of the app being measured. + return WindowIDReport( + screenRecording: CGPreflightScreenCaptureAccess(), + windows: here, + otherSpaces: all.filter { !shown.contains($0.id) } + ) + } + + /// The windows in `listed` that belong to `pid` and can be captured, + /// in the order the window server reported them, which is front to back. + /// + /// A window with no area is dropped: `AppKit` keeps zero-sized windows + /// around for panels that have never been shown, and capturing one produces + /// an empty file. + static func capturable(from listed: [[String: Any]], pid: pid_t) -> [CaptureWindow] { + return listed.compactMap { window -> CaptureWindow? in + guard integer(window[kCGWindowOwnerPID as String]) == Int(pid), + integer(window[kCGWindowLayer as String]) == normalLayer, + let number = integer(window[kCGWindowNumber as String]), + let id = CGWindowID(exactly: number), + let bounds = window[kCGWindowBounds as String] as? [String: Any], + let width = integer(bounds["Width"]), + let height = integer(bounds["Height"]), + width > 0, height > 0 + else { + return nil + } + + return CaptureWindow( + id: id, + title: window[kCGWindowName as String] as? String, + width: width, + height: height + ) + } + } + + /// One of the window server's numbers, whichever numeric type it arrives as. + /// + /// The list holds `CFNumber`s in untyped dictionaries. Bridged, those cast to + /// `Int` while the value is whole and only to `Double` otherwise, which is a + /// distinction window bounds can cross: a window on a scaled display sits at + /// fractional points. + private static func integer(_ value: Any?) -> Int? { + if let int = value as? Int { return int } + if let double = value as? Double { return Int(double) } + return nil + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/DriveKit/Windows.swift b/apps/macos/Tools/jpdrive/Sources/DriveKit/Windows.swift new file mode 100644 index 000000000..a2a5ab144 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/DriveKit/Windows.swift @@ -0,0 +1,74 @@ +import ApplicationServices +import Foundation + +/// One of an application's windows. +struct WindowSummary: Encodable, Equatable { + let identifier: String? + let title: String? + + /// Whether this is the application's main window. + let main: Bool? + + let minimized: Bool? + + /// Position and size in screen coordinates. + /// + /// Included here, unlike in a tree, because a window's frame is what the + /// listing is for: which window is where, and how big. + let frame: String? +} + +/// Lists an application's windows. +/// +/// Separate from a tree walk because the useful facts about a window are its own — +/// which one is main, which is minimized, where it sits — rather than what it +/// contains. +enum Windows { + /// Attributes read for every window, in one batch. + static let batch = [ + kAXIdentifierAttribute, + kAXTitleAttribute, + kAXMainAttribute, + kAXMinimizedAttribute, + "AXFrame", + ] + + /// List the windows of the application owning `pid`. + static func read(pid: pid_t) throws(DriveError) -> [WindowSummary] { + guard ProcessTable.record(for: pid) != nil else { + throw DriveError( + kind: .appNotRunning, + message: "no process is running under pid \(pid)", + hint: "start the app, then pass its pid: --pid $(pgrep -f JP.app)" + ) + } + + guard AXIsProcessTrusted() else { + throw DriveError( + kind: .notPermitted, + message: "not trusted to read another application's accessibility tree", + hint: DriveError.accessibilityHint + ) + } + + return list(of: AXElement.application(pid: pid)) + } + + /// The windows an application element reports. + /// + /// An application with no windows answers an empty list, which is a state a + /// running app can legitimately be in. + static func list<E: Element>(of app: E) -> [WindowSummary] { + return app.elements(kAXWindowsAttribute).map { window in + let text = window.read(batch).text + + return WindowSummary( + identifier: text[0], + title: text[1], + main: text[2].axFlag, + minimized: text[3].axFlag, + frame: text[4] + ) + } + } +} diff --git a/apps/macos/Tools/jpdrive/Sources/jpdrive/main.swift b/apps/macos/Tools/jpdrive/Sources/jpdrive/main.swift new file mode 100644 index 000000000..7e1373e35 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Sources/jpdrive/main.swift @@ -0,0 +1,3 @@ +import DriveKit + +Driver.run() diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ActTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ActTests.swift new file mode 100644 index 000000000..b5f8c9ba0 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ActTests.swift @@ -0,0 +1,249 @@ +import ApplicationServices +import Testing + +@testable import DriveKit + +@Suite("Act") +struct ActTests { + /// The case the driver exists for, and the one that was broken: the identifier + /// is on a leaf two levels below the element that owns selection. + @Test("select writes AXSelected on the row, not on the identified element") + func selectsTheOwningRow() throws { + let root = FakeElement.sidebar(rowCount: 3) + let step = Step.select(.init(identifier: "sidebar.row.1")) + + let result = try Act.run(step, in: root) + + #expect( + result + == StepResult( + step: "select", + identifier: "sidebar.row.1", + role: "AXRow", + confirmed: true + ) + ) + #expect(result.confirmed == true) + + let rows = try #require(root.children.first?.children) + #expect(rows[1].attributes[kAXSelectedAttribute] == "1") + + // The leaf that carried the identifier must not have been written to. It + // reports no AXSelected at all, and a driver that wrote there would report + // success while selecting nothing. + let leaf = try #require(rows[1].children.first?.children.first) + #expect(leaf.attributes[kAXSelectedAttribute] == nil) + } + + /// Selection reaches a row regardless of where it sits, which is what makes the + /// attribute write preferable to a synthesized click. + @Test("select reaches a row far down a long list") + func selectsADeepRow() throws { + let root = FakeElement.sidebar(rowCount: 1000) + + let result = try Act.run(.select(.init(identifier: "sidebar.row.987")), in: root) + + #expect(result.confirmed == true) + let rows = try #require(root.children.first?.children) + #expect(rows[987].attributes[kAXSelectedAttribute] == "1") + } + + @Test("select reports the identifier it could not find") + func reportsAMissingIdentifier() { + let root = FakeElement.sidebar(rowCount: 3) + + #expect(throws: DriveError.self) { + try Act.run(.select(.init(identifier: "sidebar.row.nope")), in: root) + } + } + + /// An element nothing in its chain can select is a failure, not a fallback onto + /// some other mechanism. + @Test("select fails when no ancestor accepts the write") + func failsWhenNothingIsSelectable() throws { + let leaf = FakeElement(role: "AXUnknown", identifier: "lonely") + let root = FakeElement(role: "AXApplication", children: [leaf]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.select(.init(identifier: "lonely")), in: root) + } + + #expect(error.kind == .notSelectable) + } + + /// A write the accessibility API refuses is reported, not silently treated as + /// an unconfirmed success. + @Test("select reports a refused write") + func reportsARefusedWrite() throws { + let root = FakeElement.sidebar(rowCount: 2) + let rows = try #require(root.children.first?.children) + rows[0].writeStatus = .cannotComplete + + let error = try #require(throws: DriveError.self) { + try Act.run(.select(.init(identifier: "sidebar.row.0")), in: root) + } + + #expect(error.kind == .writeFailed) + #expect(error.message.contains("cannot_complete")) + } + + /// A write can be accepted and do nothing. The step reports that as an + /// unconfirmed success rather than as a failure, because the distinction is + /// what tells a caller the mechanism stopped working. + @Test("select reports an accepted write that changed nothing") + func reportsAnIneffectiveWrite() throws { + let leaf = FakeElement(role: "AXUnknown", identifier: "row") + let row = FakeElement(role: "AXRow", settable: [kAXSelectedAttribute], children: [leaf]) + let root = FakeElement(role: "AXApplication", children: [row]) + row.ignoresWrites = true + + let result = try Act.run(.select(.init(identifier: "row")), in: root) + + #expect(result.role == "AXRow") + #expect( + result.confirmed == false, + "a write the target discarded must not report as confirmed" + ) + } + + /// A sidebar row is the case that makes `click` the wrong tool: the identified + /// element has no activation point, and `select` reaches it whether or not it + /// is on screen. + @Test("click fails on a row, which wants select instead") + func clickFailsOnARow() throws { + let root = FakeElement.sidebar(rowCount: 1) + + let error = try #require(throws: DriveError.self) { + try Act.run( + .click(.init(identifier: "sidebar.row.0")), in: root, poster: FakePoster()) + } + + #expect(error.kind == .notClickable) + } + + @Test("press performs AXPress on the identified element") + func pressesTheElement() throws { + let item = FakeElement( + role: "AXMenuItem", + identifier: "terminate:", + actions: ["AXCancel", "AXPress", "AXPick"] + ) + let root = FakeElement(role: "AXApplication", children: [item]) + + let result = try Act.run(.press(.init(identifier: "terminate:")), in: root) + + #expect(item.performed == ["AXPress"]) + #expect(result.step == "press") + #expect(result.role == "AXMenuItem") + } + + /// Nothing readable says a press worked, so the step must not claim it did. + /// Reporting `true` here would be the one dishonest field in the output. + @Test("press reports no confirmation") + func pressDoesNotClaimConfirmation() throws { + let item = FakeElement(role: "AXButton", identifier: "go", actions: ["AXPress"]) + let root = FakeElement(role: "AXApplication", children: [item]) + + let result = try Act.run(.press(.init(identifier: "go")), in: root) + + #expect(result.confirmed == nil) + } + + /// A sidebar row is the case this catches: it advertises no actions at all, so + /// the error points at the step that does work on it. + @Test("press fails on an element that does not accept it") + func pressFailsWithoutTheAction() throws { + let root = FakeElement.sidebar(rowCount: 1) + + let error = try #require(throws: DriveError.self) { + try Act.run(.press(.init(identifier: "sidebar.row.0")), in: root) + } + + #expect(error.kind == .actionUnsupported) + #expect(error.hint?.contains("select") == true) + // The press must not have been attempted anyway. + let leaf = root.children.first?.children.first?.children.first?.children.first + #expect(leaf?.performed.isEmpty == true) + } + + /// An element with other actions gets told what it does accept, which is how a + /// script author finds the right verb without dumping the tree. + @Test("press names the actions an element does accept") + func pressNamesAvailableActions() throws { + let item = FakeElement(role: "AXRow", identifier: "row", actions: ["AXShowMenu"]) + let root = FakeElement(role: "AXApplication", children: [item]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.press(.init(identifier: "row")), in: root) + } + + #expect(error.hint?.contains("AXShowMenu") == true) + } + + /// `press` is a shorthand for `perform` with `AXPress`, and must keep saying so + /// in its result rather than reporting the mechanism underneath. + @Test("press names itself, not the general mechanism") + func pressNamesItself() throws { + let item = FakeElement(role: "AXButton", identifier: "go", actions: ["AXPress"]) + let root = FakeElement(role: "AXApplication", children: [item]) + + let result = try Act.run(.press(.init(identifier: "go")), in: root) + + #expect(result.step == "press") + } + + /// The escape hatch for the actions with no step of their own. A text field + /// offers `AXConfirm` and no `AXPress`, so this is the only way to reach it. + @Test("perform runs any action the element advertises") + func performsANamedAction() throws { + let field = FakeElement( + role: "AXTextField", + identifier: "sidebar.filter", + actions: ["AXShowMenu", "AXConfirm"] + ) + let root = FakeElement(role: "AXApplication", children: [field]) + + let result = try Act.run( + .perform(.init(identifier: "sidebar.filter", action: "AXConfirm")), + in: root + ) + + #expect(field.performed == ["AXConfirm"]) + #expect(result.step == "perform") + } + + @Test("perform fails on an action the element does not advertise") + func performRejectsAnUnknownAction() throws { + let field = FakeElement( + role: "AXTextField", + identifier: "sidebar.filter", + actions: ["AXConfirm"] + ) + let root = FakeElement(role: "AXApplication", children: [field]) + + let error = try #require(throws: DriveError.self) { + try Act.run( + .perform(.init(identifier: "sidebar.filter", action: "AXPress")), + in: root + ) + } + + #expect(error.kind == .actionUnsupported) + #expect(error.hint == "it accepts: AXConfirm") + #expect(field.performed.isEmpty) + } + + @Test("press reports a refused action") + func pressReportsARefusal() throws { + let item = FakeElement(role: "AXButton", identifier: "go", actions: ["AXPress"]) + item.performStatus = .cannotComplete + let root = FakeElement(role: "AXApplication", children: [item]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.press(.init(identifier: "go")), in: root) + } + + #expect(error.kind == .actionFailed) + #expect(error.message.contains("cannot_complete")) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ArgumentsTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ArgumentsTests.swift new file mode 100644 index 000000000..41534c061 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ArgumentsTests.swift @@ -0,0 +1,201 @@ +import Testing + +@testable import DriveKit + +@Suite("Arguments") +struct ArgumentsTests { + @Test("doctor takes an optional pid") + func doctorPid() throws { + guard case .doctor(let pid) = try Arguments.parse(["doctor", "--pid", "42"]) else { + Issue.record("expected a doctor command") + return + } + #expect(pid == 42) + + guard case .doctor(let none) = try Arguments.parse(["doctor"]) else { + Issue.record("expected a doctor command") + return + } + #expect(none == nil) + } + + @Test("tree defaults its bounds") + func treeDefaults() throws { + guard case .tree(let options) = try Arguments.parse(["tree", "--pid", "42"]) else { + Issue.record("expected a tree command") + return + } + + #expect(options.pid == 42) + #expect(options.identifierPrefix == nil) + #expect(options.maxMatches == Arguments.defaultMatches) + #expect(options.maxDepth == Arguments.defaultDepth) + #expect(options.maxSiblings == Arguments.defaultSiblings) + #expect(!options.frames) + } + + @Test("tree takes every bound") + func treeFlags() throws { + let parsed = try Arguments.parse([ + "tree", "--pid", "42", "--identifier", "sidebar.", "--max-matches", "1", + "--depth", "3", "--max-siblings", "0", "--frames", + ]) + + guard case .tree(let options) = parsed else { + Issue.record("expected a tree command") + return + } + + #expect(options.identifierPrefix == "sidebar.") + #expect(options.maxMatches == 1) + #expect(options.maxDepth == 3) + #expect(options.maxSiblings == 0) + #expect(options.frames) + } + + /// Zero means "every sibling", which is a different thing from the cap being + /// unset, so it has to survive parsing rather than be rejected as non-positive. + @Test("max-siblings accepts zero for no cap") + func zeroSiblingsIsAllowed() throws { + guard + case .dump(let options) = try Arguments.parse([ + "dump", "--pid", "1", "--max-siblings", "0", + ]) + else { + Issue.record("expected a dump command") + return + } + + #expect(options.maxSiblings == 0) + } + + @Test("windows takes only a pid") + func windowsPid() throws { + guard case .windows(let pid) = try Arguments.parse(["windows", "--pid", "42"]) else { + Issue.record("expected a windows command") + return + } + #expect(pid == 42) + } + + @Test("windowid takes only a pid") + func windowidPid() throws { + guard case .windowid(let pid) = try Arguments.parse(["windowid", "--pid", "42"]) else { + Issue.record("expected a windowid command") + return + } + #expect(pid == 42) + } + + /// A menu bar is small and every item in it is a thing to press, so the sibling + /// cap that keeps a thousand-row list readable would only hide verbs here. + @Test("menu walks every sibling by default") + func menuHasNoSiblingCap() throws { + guard case .menu(let pid, let options) = try Arguments.parse(["menu", "--pid", "42"]) + else { + Issue.record("expected a menu command") + return + } + + #expect(pid == 42) + #expect(options.maxSiblings == 0) + } + + @Test("act decodes a step") + func actStep() throws { + let parsed = try Arguments.parse([ + "act", "--pid", "42", "--json", #"{"select":{"identifier":"sidebar.row.7"}}"#, + ]) + + guard case .act(let step, let pid) = parsed else { + Issue.record("expected an act command") + return + } + + #expect(pid == 42) + guard case .select(let target) = step else { + Issue.record("expected a select step") + return + } + #expect(target.identifier == "sidebar.row.7") + } + + /// Every field of a step is spelled the way the tool definition documents it, + /// and a mismatch is silent: an unrecognised key decodes as absent, so a wait + /// given a short timeout would wait the default instead and the run would look + /// merely slow. + @Test("act decodes every field of a wait") + func actWaitFields() throws { + let parsed = try Arguments.parse([ + "act", "--pid", "42", "--json", + #"{"wait_for":{"identifier":"transcript.scroll","under":"sidebar.list","timeout_ms":1500,"interval_ms":25}}"#, + ]) + + guard case .act(let step, _) = parsed, case .waitFor(let target) = step else { + Issue.record("expected a wait_for step") + return + } + + #expect(target.identifier == "transcript.scroll") + #expect(target.under == "sidebar.list") + #expect(target.timeoutMs == 1500) + #expect(target.intervalMs == 25) + } + + @Test( + "a step naming no known verb is rejected", + arguments: [ + #"{"nope":{"identifier":"x"}}"#, + #"{"wait":{"identifier":"x"}}"#, + #"{}"#, + #"not json"#, + #"{"select":{}}"#, + ] + ) + func rejectsAMalformedStep(json: String) throws { + let error = try #require(throws: DriveError.self) { + try Arguments.parse(["act", "--pid", "1", "--json", json]) + } + + #expect(error.kind == .badUsage) + } + + @Test( + "a command missing its pid is rejected", + arguments: [ + ["tree"], ["dump"], ["windows"], ["windowid"], ["menu"], ["act", "--json", "{}"], + ] + ) + func requiresAPid(arguments: [String]) throws { + let error = try #require(throws: DriveError.self) { + try Arguments.parse(arguments) + } + + #expect(error.kind == .badUsage) + } + + /// An empty command substitution is the shape this most often takes: + /// `--pid $(pgrep -f JP.app)` expands to nothing when the app is not running, + /// leaving the flag with no value. + @Test("a pid flag with no value is rejected with a usable hint") + func rejectsAMissingPidValue() throws { + let error = try #require(throws: DriveError.self) { + try Arguments.parse(["doctor", "--pid"]) + } + + #expect(error.kind == .badUsage) + #expect(error.hint?.contains("pgrep") == true) + } + + @Test( + "unknown input is rejected", + arguments: [["fly", "--pid", "1"], ["tree", "--pid", "1", "--nope"], []] + ) + func rejectsUnknownInput(arguments: [String]) throws { + let error = try #require(throws: DriveError.self) { + try Arguments.parse(arguments) + } + + #expect(error.kind == .badUsage) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ClickTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ClickTests.swift new file mode 100644 index 000000000..d9986c121 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ClickTests.swift @@ -0,0 +1,115 @@ +import ApplicationServices +import Testing + +@testable import DriveKit + +@Suite("Act.click") +struct ClickTests { + /// A button inside a window, which is the shape a click needs: something with a + /// point, under something that can be raised. + private func app(activationPoint: CGPoint? = CGPoint(x: 120, y: 340)) -> FakeElement { + let button = FakeElement( + role: "AXButton", identifier: "toolbar.open", actions: ["AXPress"]) + if let activationPoint { + button.points[AXElement.activationPoint] = activationPoint + } + + let window = FakeElement( + role: kAXWindowRole, + identifier: "workspace-AppWindow-1", + actions: [kAXRaiseAction], + children: [button] + ) + + return FakeElement(role: "AXApplication", children: [window]) + } + + @Test("clicks where the element says a click belongs") + func clicksTheActivationPoint() throws { + let root = app() + let poster = FakePoster() + + let result = try Act.run( + .click(.init(identifier: "toolbar.open")), + in: root, + poster: poster + ) + + #expect(poster.clicks == [CGPoint(x: 120, y: 340)]) + #expect(result.step == "click") + #expect(result.role == "AXButton") + #expect(result.point == "120.0,340.0") + } + + /// The event goes to whatever occupies the coordinate, so a window behind + /// another one would have its click swallowed. Raising is what makes the + /// coordinate mean the element that named it. + @Test("raises the window before clicking") + func raisesTheWindowFirst() throws { + let root = app() + let window = try #require(root.children.first) + + _ = try Act.run( + .click(.init(identifier: "toolbar.open")), in: root, poster: FakePoster()) + + #expect(window.performed == [kAXRaiseAction]) + } + + /// A sidebar row has no activation point of its own, and pointing at `select` + /// is more use than clicking at the origin would be. + @Test("fails on an element with nowhere to click") + func failsWithoutAnActivationPoint() throws { + let poster = FakePoster() + + let error = try #require(throws: DriveError.self) { + try Act.run( + .click(.init(identifier: "toolbar.open")), + in: app(activationPoint: nil), + poster: poster + ) + } + + #expect(error.kind == .notClickable) + #expect(error.hint?.contains("select") == true) + #expect(poster.clicks.isEmpty, "nothing may be clicked when there is no point to click") + } + + @Test("reports a click that could not be posted") + func reportsAFailedPost() throws { + let poster = FakePoster() + poster.succeeds = false + + let error = try #require(throws: DriveError.self) { + try Act.run(.click(.init(identifier: "toolbar.open")), in: app(), poster: poster) + } + + #expect(error.kind == .actionFailed) + #expect(error.message.contains("120.0,340.0")) + } + + @Test("reports an identifier it could not find") + func reportsAMissingIdentifier() throws { + let poster = FakePoster() + + let error = try #require(throws: DriveError.self) { + try Act.run(.click(.init(identifier: "nope")), in: app(), poster: poster) + } + + #expect(error.kind == .identifierNotFound) + #expect(poster.clicks.isEmpty) + } + + /// An element outside any window still has a point, and clicking it is better + /// than refusing because there was nothing to raise. + @Test("clicks without a window to raise") + func clicksWithoutAWindow() throws { + let element = FakeElement(role: "AXButton", identifier: "loose") + element.points[AXElement.activationPoint] = CGPoint(x: 1, y: 2) + let root = FakeElement(role: "AXApplication", children: [element]) + let poster = FakePoster() + + _ = try Act.run(.click(.init(identifier: "loose")), in: root, poster: poster) + + #expect(poster.clicks == [CGPoint(x: 1, y: 2)]) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/DragTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/DragTests.swift new file mode 100644 index 000000000..93e471d96 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/DragTests.swift @@ -0,0 +1,229 @@ +import ApplicationServices +import Testing + +@testable import DriveKit + +@Suite("Act.drag") +struct DragTests { + /// A window with a known frame, which is all a drag needs: somewhere to + /// measure fractions against, and something to raise. + private func app( + origin: CGPoint? = CGPoint(x: 100, y: 200), + size: CGSize? = CGSize(width: 800, height: 600) + ) -> FakeElement { + let window = FakeElement( + role: kAXWindowRole, + identifier: "workspace-AppWindow-1", + actions: [kAXRaiseAction] + ) + if let origin { + window.points[kAXPositionAttribute] = origin + } + if let size { + window.sizes[kAXSizeAttribute] = size + } + + return FakeElement(role: "AXApplication", children: [window]) + } + + private func step( + from: (Double, Double), + to: (Double, Double), + steps: Int? = nil, + pauseMs: Int? = nil + ) -> Step { + .drag( + .init( + identifier: "workspace-AppWindow-1", + from: .init(dx: from.0, dy: from.1), + to: .init(dx: to.0, dy: to.1), + steps: steps, + pauseMs: pauseMs + ) + ) + } + + /// Fractions are resolved against the element's own frame, so a script says + /// "the right edge, halfway down" rather than a screen coordinate that stops + /// being right the moment the window moves. + @Test("resolves fractional offsets against the element's frame") + func resolvesOffsets() throws { + let poster = FakePoster() + + _ = try Act.run( + step(from: (1.0, 0.5), to: (0.5, 0.5), steps: 2), in: app(), poster: poster) + + // Right edge, halfway down: 100 + 800, 200 + 300. Halfway across: 100 + 400. + #expect( + poster.drags == [ + [ + CGPoint(x: 900, y: 500), + CGPoint(x: 700, y: 500), + CGPoint(x: 500, y: 500), + ] + ] + ) + } + + /// The step exists to produce many frames rather than one jump, so the count + /// is asserted rather than assumed: a drag delivered as a single move cannot + /// show what a view does *during* a gesture, which is the whole reason for it. + @Test("posts one move per step, plus the press") + func postsOneMovePerStep() throws { + let poster = FakePoster() + + let result = try Act.run( + step(from: (0, 0), to: (1, 0), steps: 12), in: app(), poster: poster) + + #expect(poster.drags.first?.count == 13) + #expect(result.moves == 12) + #expect(result.step == "drag") + #expect(result.role == kAXWindowRole) + } + + @Test("defaults to enough moves to be a gesture") + func defaultsToAGesture() throws { + let poster = FakePoster() + + _ = try Act.run(step(from: (0, 0), to: (1, 1)), in: app(), poster: poster) + + #expect(poster.drags.first?.count == 25) + #expect(poster.pauses == [.milliseconds(8)]) + } + + @Test("honours a stated pause between moves") + func honoursThePause() throws { + let poster = FakePoster() + + _ = try Act.run( + step(from: (0, 0), to: (1, 1), steps: 3, pauseMs: 40), in: app(), poster: poster) + + #expect(poster.pauses == [.milliseconds(40)]) + } + + /// A drag of zero steps is a click with extra words. Clamped rather than + /// rejected, so a caller computing the count from a distance cannot produce a + /// path with nothing in it. + @Test("clamps a step count below one") + func clampsZeroSteps() throws { + let poster = FakePoster() + + _ = try Act.run( + step(from: (0, 0), to: (1, 1), steps: 0), in: app(), poster: poster) + + #expect(poster.drags.first?.count == 2) + } + + /// The events land on whatever occupies the coordinates, so a window behind + /// another would have the gesture swallowed. + @Test("raises the window before dragging") + func raisesTheWindowFirst() throws { + let root = app() + let window = try #require(root.children.first) + + _ = try Act.run(step(from: (1, 0.5), to: (0.5, 0.5)), in: root, poster: FakePoster()) + + #expect(window.performed == [kAXRaiseAction]) + } + + /// Raising alone is not enough, and this is the assertion that says so. + /// + /// `AXRaise` orders a window forward within its own application; the ordering + /// *between* applications follows activation. A drag posted at a background + /// window's coordinates without this was received by the frontmost terminal + /// instead — measured, and the reason the step takes focus. + @Test("brings the application forward before dragging") + func activatesTheApplication() throws { + let root = app() + + _ = try Act.run(step(from: (1, 0.5), to: (0.5, 0.5)), in: root, poster: FakePoster()) + + #expect(root.flag(kAXFrontmostAttribute) == true) + } + + /// A tree that is not a running application has nothing to activate, and a + /// gesture against one is still worth posting: every other assertion in this + /// file depends on that. + @Test("drags even when the application cannot be brought forward") + func dragsWithoutActivating() throws { + let root = app() + root.writeStatus = .cannotComplete + let poster = FakePoster() + + _ = try Act.run(step(from: (0, 0), to: (1, 1), steps: 2), in: root, poster: poster) + + #expect(poster.drags.first?.count == 3) + } + + @Test("fails on an element with no frame to measure") + func failsWithoutAFrame() throws { + let poster = FakePoster() + + let error = try #require(throws: DriveError.self) { + try Act.run(step(from: (0, 0), to: (1, 1)), in: app(size: nil), poster: poster) + } + + #expect(error.kind == .notClickable) + #expect(poster.drags.isEmpty, "nothing may be dragged across a frame that is not known") + } + + @Test("reports a drag that could not be posted") + func reportsAFailedPost() throws { + let poster = FakePoster() + poster.succeeds = false + + let error = try #require(throws: DriveError.self) { + try Act.run(step(from: (0, 0), to: (1, 1)), in: app(), poster: poster) + } + + #expect(error.kind == .actionFailed) + #expect(error.message.contains("100.0,200.0")) + } + + @Test("reports an identifier it could not find") + func reportsAMissingIdentifier() throws { + let poster = FakePoster() + + let error = try #require(throws: DriveError.self) { + try Act.run( + .drag( + .init( + identifier: "nope", + from: .init(dx: 0, dy: 0), + to: .init(dx: 1, dy: 1), + steps: nil, + pauseMs: nil + ) + ), + in: app(), + poster: poster + ) + } + + #expect(error.kind == .identifierNotFound) + #expect(poster.drags.isEmpty) + } + + /// Decoded from the wire, because the snake-cased key is spelled by hand and a + /// mismatch there reads as the default silently applying. + @Test("decodes a step from its written form") + func decodesFromJSON() throws { + let json = """ + {"drag": {"identifier": "transcript.text", "from": {"dx": 0.1, "dy": 0.2}, + "to": {"dx": 0.8, "dy": 0.6}, "steps": 6, "pause_ms": 15}} + """ + + let decoded = try JSONDecoder().decode(Step.self, from: Data(json.utf8)) + + guard case .drag(let target) = decoded else { + Issue.record("expected a drag step, got \(decoded)") + return + } + + #expect(target.identifier == "transcript.text") + #expect(target.from.dx == 0.1) + #expect(target.to.dy == 0.6) + #expect(target.steps == 6) + #expect(target.pauseMs == 15) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakeElement.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakeElement.swift new file mode 100644 index 000000000..aa8deef81 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakeElement.swift @@ -0,0 +1,173 @@ +import ApplicationServices + +@testable import DriveKit + +/// An accessibility element that is not one. +/// +/// Reference semantics on purpose: a step writes to an element and then reads it +/// back, and a test asserts on what was written. With a value type the write would +/// land on a copy and every such assertion would pass vacuously. +final class FakeElement: Element { + /// Attribute values by name. A name that is absent here reads as `nil`, which + /// is what the real implementation answers for an attribute the element does + /// not report. + var attributes: [String: String] + + /// Attribute names this element accepts writes for. + let settable: Set<String> + + var actions: [String] + var children: [FakeElement] + + /// Counts every call to ``read(_:)``, so a test can pin how much of a tree a + /// walk touched rather than only what it returned. + private(set) var reads = 0 + + /// What `setFlag` should answer, for exercising a refused write. + var writeStatus: AXError = .success + + /// Accept writes and discard them. + /// + /// The accessibility API lets a target answer `success` and then do nothing, + /// which is why a step reads back rather than trusting the status. Without this + /// there is no way to tell a driver that reads back from one that pretends to. + var ignoresWrites = false + + /// Called during every ``read(_:)``, after ``reads`` is incremented and before + /// children are handed back. + /// + /// This is how a test makes an element appear partway through a wait. A fixture + /// that has the element from the start cannot tell polling from a single lucky + /// look. + var onRead: ((FakeElement) -> Void)? + + /// Element-valued attributes, such as `AXWindows` and `AXMenuBar`. + var related: [String: [FakeElement]] = [:] + + /// Point-valued attributes, such as `AXActivationPoint`. + var points: [String: CGPoint] = [:] + + /// Size-valued attributes, such as `AXSize`. + var sizes: [String: CGSize] = [:] + + /// Actions performed on this element, in order. + private(set) var performed: [String] = [] + + /// What `perform` should answer, for exercising a refused action. + var performStatus: AXError = .success + + init( + role: String, + identifier: String? = nil, + label: String? = nil, + settable: Set<String> = [], + actions: [String] = [], + children: [FakeElement] = [] + ) { + self.attributes = [kAXRoleAttribute: role] + self.attributes[kAXIdentifierAttribute] = identifier + self.attributes[AXElement.attributedDescription] = label + self.settable = settable + self.actions = actions + self.children = children + } + + func read(_ names: [String]) -> Reading<FakeElement> { + reads += 1 + onRead?(self) + return Reading(text: names.map { attributes[$0] }, children: children) + } + + func isSettable(_ name: String) -> Bool { + return settable.contains(name) + } + + func flag(_ name: String) -> Bool? { + switch attributes[name] { + case "1": return true + case "0": return false + default: return nil + } + } + + func setFlag(_ name: String, _ value: Bool) -> AXError { + guard writeStatus == .success else { return writeStatus } + guard !ignoresWrites else { return .success } + attributes[name] = value ? "1" : "0" + return .success + } + + func setText(_ name: String, _ value: String) -> AXError { + guard writeStatus == .success else { return writeStatus } + guard !ignoresWrites else { return .success } + attributes[name] = value + return .success + } + + func perform(_ action: String) -> AXError { + guard performStatus == .success else { return performStatus } + performed.append(action) + return .success + } + + func point(_ name: String) -> CGPoint? { + return points[name] + } + + func size(_ name: String) -> CGSize? { + return sizes[name] + } + + func setSize(_ name: String, _ value: CGSize) -> AXError { + guard writeStatus == .success else { return writeStatus } + guard !ignoresWrites else { return .success } + sizes[name] = value + return .success + } + + func elements(_ name: String) -> [FakeElement] { + return related[name] ?? [] + } +} + +extension FakeElement { + /// The sidebar shape this app actually produces, at whatever size a test needs. + /// + /// Three elements per conversation, with the identifier on the leaf and + /// `AXSelected` writable only on the row. Reproducing that here is the point: + /// the driver has to address one element and act on another. + static func sidebar(rowCount: Int) -> FakeElement { + let rows = (0..<rowCount).map { index in + FakeElement( + role: "AXRow", + settable: [kAXSelectedAttribute], + actions: ["AXShowDefaultUI"], + children: [ + FakeElement( + role: "AXCell", + children: [ + FakeElement( + role: "AXUnknown", + identifier: "sidebar.row.\(index)", + label: "Conversation \(index), 4 events" + ) + ] + ) + ] + ) + } + + return FakeElement( + role: "AXApplication", + children: [ + FakeElement( + role: "AXOutline", + identifier: "sidebar.list", + label: "Conversations", + actions: ["AXShowMenu"], + children: rows + ) + ] + ) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakePoster.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakePoster.swift new file mode 100644 index 000000000..808d79006 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/FakePoster.swift @@ -0,0 +1,35 @@ +import ApplicationServices + +@testable import DriveKit + +/// Records clicks and drags instead of posting them. +/// +/// A real click goes to the window server and lands on whatever occupies the +/// coordinate, so the only part a test can hold still is where the driver aimed. +final class FakePoster: EventPoster { + /// Every point clicked, in order. + private(set) var clicks: [CGPoint] = [] + + /// Every drag's path, in order. + private(set) var drags: [[CGPoint]] = [] + + /// The pause each drag was asked to wait between moves. + private(set) var pauses: [Duration] = [] + + /// What `click` and `drag` should answer, for exercising a post that could + /// not be built. + var succeeds = true + + func click(at point: CGPoint) -> Bool { + guard succeeds else { return false } + clicks.append(point) + return true + } + + func drag(through path: [CGPoint], pausing pause: Duration) -> Bool { + guard succeeds else { return false } + drags.append(path) + pauses.append(pause) + return true + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/MenuTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/MenuTests.swift new file mode 100644 index 000000000..002559049 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/MenuTests.swift @@ -0,0 +1,272 @@ +import ApplicationServices +import Testing + +@testable import DriveKit + +/// The menu bar's own walk is [`Tree`](Tree)'s, already covered by `TreeTests`. +/// What is specific here is the root: the menu bar hangs off an attribute of the +/// application rather than sitting in its children, and every level of it should be +/// reported rather than capped. +@Suite("Menu") +struct MenuTests { + /// The whole menu bar, with no cap, because every item in it is something a + /// script might press. + private func options() -> TreeOptions { + return TreeOptions( + pid: 0, + identifierPrefix: nil, + maxMatches: 100, + maxDepth: 20, + maxSiblings: 0, + frames: false + ) + } + + /// An application whose menu bar hangs off the attribute the real one uses. + private func app() -> FakeElement { + let app = FakeElement(role: "AXApplication") + app.related[kAXMenuBarAttribute] = [menuBar()] + return app + } + + private func menuBar() -> FakeElement { + return FakeElement( + role: "AXMenuBar", + children: [ + FakeElement( + role: "AXMenuBarItem", + label: "File", + children: [ + FakeElement( + role: "AXMenu", + children: [ + FakeElement( + role: "AXMenuItem", + identifier: "performClose:", + label: "Close", + actions: ["AXCancel", "AXPress", "AXPick"] + ), + FakeElement( + role: "AXMenuItem", + identifier: "closeAll:", + label: "Close All", + actions: ["AXCancel", "AXPress", "AXPick"] + ), + ] + ) + ] + ) + ] + ) + } + + @Test("every menu item is reported, with the action that activates it") + func reportsEveryItem() throws { + let tree = try #require(Tree.walk(from: menuBar(), options: options())) + + #expect(tree.role == "AXMenuBar") + let items = try #require(tree.children.first?.children.first?.children) + #expect(items.count == 2) + #expect(items.map(\.identifier) == ["performClose:", "closeAll:"]) + #expect(items[0].actions.contains("AXPress")) + #expect(tree.children.first?.children.first?.elidedChildren == nil) + } + + /// Menu items advertise `AXPress` where a list row advertises nothing, so they + /// are the case `press` was built for. + @Test("a menu item can be pressed by identifier") + func pressesAMenuItem() throws { + let bar = menuBar() + + let result = try Act.run(.press(.init(identifier: "closeAll:")), in: bar) + + #expect(result.role == "AXMenuItem") + let items = try #require(bar.children.first?.children.first?.children) + #expect(items[1].performed == ["AXPress"]) + #expect(items[0].performed.isEmpty, "only the addressed item may be pressed") + } + + /// The path names the two titled levels a user sees and skips the `AXMenu` + /// between them, because that container has no title to name. + @Test("a titled path resolves through the intervening menu") + func resolvesATitledPath() throws { + let root = app() + + let result = try Act.run(.menu(.init(path: ["File", "Close All"])), in: root) + + #expect(result.step == "menu") + #expect(result.identifier == "File > Close All") + #expect(result.role == "AXMenuItem") + + let bar = try #require(root.elements(kAXMenuBarAttribute).first) + let items = try #require(bar.children.first?.children.first?.children) + #expect(items[1].performed == ["AXPress"]) + #expect(items[0].performed.isEmpty) + } + + /// The point of addressing by title: an item that moved to another menu keeps + /// its identifier, so only a path notices. The failure has to say what the + /// level does hold, or the test that catches the move cannot say what changed. + @Test("a path that does not resolve names what the level holds") + func reportsWhatTheLevelHolds() throws { + let error = try #require(throws: DriveError.self) { + try Act.run(.menu(.init(path: ["File", "Quit"])), in: app()) + } + + #expect(error.kind == .notFound) + #expect(error.message.contains("'File' holds no item titled 'Quit'")) + #expect(error.hint == "it holds: Close, Close All") + } + + /// A context menu's items cannot be addressed any other way: `SwiftUI` gives + /// every one of them the same selector name, so a title is all there is. + @Test("a path can start at the menu an element is showing") + func resolvesUnderAShownMenu() throws { + let item = FakeElement(role: "AXMenuItem", label: "Copy Link", actions: ["AXPress"]) + let owner = FakeElement( + role: "AXOutline", + identifier: "sidebar.list", + children: [ + FakeElement(role: "AXRow", identifier: "sidebar.row.0"), + FakeElement(role: "AXMenu", children: [item]), + ] + ) + let root = FakeElement(role: "AXApplication", children: [owner]) + + let result = try Act.run( + .menu(.init(path: ["Copy Link"], under: "sidebar.list")), in: root) + + #expect(result.step == "menu") + #expect(result.identifier == "Copy Link") + #expect(item.performed == ["AXPress"]) + } + + /// The menu closes as soon as the application deactivates, so "press an item + /// in it" fails far more often than "open it" does. The error has to name the + /// step that was missed. + @Test("a path under an element that shows no menu says how to open one") + func reportsAnUnshownMenu() throws { + let owner = FakeElement( + role: "AXOutline", + identifier: "sidebar.list", + children: [FakeElement(role: "AXRow", identifier: "sidebar.row.0")] + ) + let root = FakeElement(role: "AXApplication", children: [owner]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.menu(.init(path: ["Copy Link"], under: "sidebar.list")), in: root) + } + + #expect(error.kind == .notFound) + #expect(error.message == "sidebar.list is not showing a menu") + #expect(error.hint?.contains("AXShowMenu") == true) + } + + @Test("a missing top-level menu is reported against the bar") + func reportsAMissingTopLevelMenu() throws { + let error = try #require(throws: DriveError.self) { + try Act.run(.menu(.init(path: ["Edit", "Copy"])), in: app()) + } + + #expect(error.kind == .notFound) + #expect(error.message.contains("the menu bar holds no item titled 'Edit'")) + #expect(error.hint == "it holds: File") + } + + /// Stopping at a bar item addresses the menu, not an item in it, and pressing a + /// menu is not what the script meant. + @Test("a path stopping at a submenu is rejected") + func rejectsAPathToASubmenu() throws { + let error = try #require(throws: DriveError.self) { + try Act.run(.menu(.init(path: ["File"])), in: app()) + } + + #expect(error.kind == .actionUnsupported) + #expect(error.hint?.contains("name the item inside it") == true) + } + + @Test("an empty path is rejected") + func rejectsAnEmptyPath() throws { + let error = try #require(throws: DriveError.self) { + try Act.run(.menu(.init(path: [])), in: app()) + } + + #expect(error.kind == .badUsage) + } + + /// The reason every menu test would otherwise pass while the real thing did + /// nothing: AppKit disables every item acting on the front window or the + /// responder chain while the application is in the background, which a driven + /// app always is. + @Test("a menu step brings the application forward first") + func bringsTheApplicationForward() throws { + let root = app() + + _ = try Act.run(.menu(.init(path: ["File", "Close All"])), in: root) + + #expect(root.flag(kAXFrontmostAttribute) == true) + } + + /// An application already in front must not be written to: the write is what + /// steals focus, and a run of several menu steps would take it repeatedly. + @Test("an application already in front is left alone") + func leavesAFrontApplicationAlone() throws { + let root = app() + root.attributes[kAXFrontmostAttribute] = "1" + // Any write from here on fails, so a needless one fails the step. + root.writeStatus = .failure + + let result = try Act.run(.menu(.init(path: ["File", "Close All"])), in: root) + + #expect(result.identifier == "File > Close All") + } + + /// A disabled item swallows `AXPress` and answers success, so a step that + /// pressed it anyway would report having done something it did not do. + @Test("a disabled item is refused rather than pressed") + func refusesADisabledItem() throws { + let root = app() + let bar = try #require(root.elements(kAXMenuBarAttribute).first) + let items = try #require(bar.children.first?.children.first?.children) + items[1].attributes[kAXEnabledAttribute] = "0" + + let error = try #require(throws: DriveError.self) { + try Act.run( + .menu(.init(path: ["File", "Close All"])), + in: root, + activation: .milliseconds(1) + ) + } + + #expect(error.kind == .disabled) + #expect(error.message == "'File > Close All' is disabled") + #expect(items[1].performed.isEmpty, "a disabled item must not be pressed") + } + + /// Most elements report no `AXEnabled` at all, and reading its absence as a + /// refusal would reject every one of them. + @Test("an item reporting no enabled state is pressed") + func pressesAnItemWithNoEnabledState() throws { + let root = app() + + let result = try Act.run( + .menu(.init(path: ["File", "Close All"])), + in: root, + activation: .milliseconds(1) + ) + + #expect(result.identifier == "File > Close All") + } + + /// The menu bar comes from the application's attribute. An app without one is a + /// real case, and it must not be reported as a missing menu item. + @Test("an application with no menu bar says so") + func reportsNoMenuBar() throws { + let error = try #require(throws: DriveError.self) { + try Act.run(.menu(.init(path: ["File"])), in: FakeElement(role: "AXApplication")) + } + + #expect(error.kind == .notFound) + #expect(error.message.contains("no menu bar")) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/PixelsTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/PixelsTests.swift new file mode 100644 index 000000000..28bef284e --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/PixelsTests.swift @@ -0,0 +1,231 @@ +import CoreGraphics +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers + +@testable import DriveKit + +/// Tests for reading a screenshot's pixels. +/// +/// Everything here works against a PNG written by the test, so none of it needs a +/// window server, a running app or a Screen Recording grant. +@Suite("Pixels") +struct PixelsTests { + /// A four-by-two image, written to a temporary file and removed afterwards. + /// + /// Rows top to bottom, each row left to right, as `#RRGGBB` strings. Written + /// through `CGImageDestination` so the file is a real PNG decoded by the same + /// path a screenshot takes. + private func withImage( + rows: [[String]], + _ body: (String) throws -> Void + ) throws { + let height = rows.count + let width = try #require(rows.first?.count) + + var bytes: [UInt8] = [] + for row in rows { + for hex in row { + let value = try #require(UInt32(hex.dropFirst(), radix: 16)) + bytes.append(UInt8((value >> 16) & 0xFF)) + bytes.append(UInt8((value >> 8) & 0xFF)) + bytes.append(UInt8(value & 0xFF)) + bytes.append(255) + } + } + + let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let provider = try #require(CGDataProvider(data: Data(bytes) as CFData)) + let image = try #require( + CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: width * 4, + space: space, + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + )) + + let path = NSTemporaryDirectory() + "/jpdrive-pixels-\(UUID().uuidString).png" + let url = URL(fileURLWithPath: path) as CFURL + let destination = try #require( + CGImageDestinationCreateWithURL(url, UTType.png.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, image, nil) + #expect(CGImageDestinationFinalize(destination)) + + defer { try? FileManager.default.removeItem(atPath: path) } + try body(path) + } + + /// Two colours across a row, which is the shape every real question takes: a + /// wide background, a narrow line, and the offset where one becomes the other. + @Test("collapses a row into runs of one colour") + func scansARow() throws { + try withImage(rows: [ + ["#FFFFFF", "#FFFFFF", "#DBDBDB", "#FFFFFF"], + ["#000000", "#000000", "#000000", "#000000"], + ]) { path in + let report = try Pixels.read( + PixelOptions(image: path, axis: .row, at: 0, from: nil, to: nil)) + + #expect(report.width == 4) + #expect(report.height == 2) + #expect(report.colorSpace == "sRGB") + #expect( + report.runs == [ + PixelRun(start: 0, count: 2, color: "#FFFFFF"), + PixelRun(start: 2, count: 1, color: "#DBDBDB"), + PixelRun(start: 3, count: 1, color: "#FFFFFF"), + ] + ) + } + } + + /// Rows are indexed down from the top, the way a screenshot is read, not up + /// from the bottom the way CoreGraphics draws. + @Test("counts rows down from the top") + func rowsCountFromTheTop() throws { + try withImage(rows: [ + ["#FF0000", "#FF0000"], + ["#00FF00", "#00FF00"], + ["#0000FF", "#0000FF"], + ]) { path in + let top = try Pixels.read( + PixelOptions(image: path, axis: .row, at: 0, from: nil, to: nil)) + let bottom = try Pixels.read( + PixelOptions(image: path, axis: .row, at: 2, from: nil, to: nil)) + + #expect(top.runs == [PixelRun(start: 0, count: 2, color: "#FF0000")]) + #expect(bottom.runs == [PixelRun(start: 0, count: 2, color: "#0000FF")]) + } + } + + @Test("collapses a column into runs of one colour") + func scansAColumn() throws { + try withImage(rows: [ + ["#FFFFFF", "#111111"], + ["#FFFFFF", "#111111"], + ["#222222", "#111111"], + ]) { path in + let report = try Pixels.read( + PixelOptions(image: path, axis: .column, at: 0, from: nil, to: nil)) + + #expect(report.scan == "column") + #expect( + report.runs == [ + PixelRun(start: 0, count: 2, color: "#FFFFFF"), + PixelRun(start: 2, count: 1, color: "#222222"), + ] + ) + } + } + + /// A window is nine hundred points wide and the interesting part is a few of + /// them, so a scan can be bounded. The offsets stay absolute, because they are + /// what gets compared against a frame from the accessibility tree. + @Test("bounds a scan and keeps the offsets absolute") + func boundsAScan() throws { + try withImage(rows: [["#FFFFFF", "#AAAAAA", "#BBBBBB", "#FFFFFF"]]) { path in + let report = try Pixels.read( + PixelOptions(image: path, axis: .row, at: 0, from: 1, to: 2)) + + #expect( + report.runs == [ + PixelRun(start: 1, count: 1, color: "#AAAAAA"), + PixelRun(start: 2, count: 1, color: "#BBBBBB"), + ] + ) + } + } + + /// Reading past the edge is a mistake worth reporting rather than clamping: a + /// silently moved scan answers a question nobody asked. + @Test("refuses a line outside the image") + func refusesALineOutside() throws { + try withImage(rows: [["#FFFFFF"]]) { path in + #expect(throws: DriveError.self) { + try Pixels.read( + PixelOptions(image: path, axis: .row, at: 7, from: nil, to: nil)) + } + } + } + + @Test("reports an image it cannot read") + func reportsAMissingImage() { + #expect(throws: DriveError.self) { + try Pixels.read( + PixelOptions( + image: "/no/such/screenshot.png", axis: .row, at: 0, from: nil, to: nil)) + } + } + + /// A translucent pixel carries its alpha, so it cannot be mistaken for an + /// opaque one of the same colour. + @Test("spells an opaque colour without alpha and a translucent one with it") + func spellsAlphaOnlyWhenItMatters() { + #expect(Pixel(red: 0xDB, green: 0xDB, blue: 0xDB, alpha: 255).hex == "#DBDBDB") + #expect(Pixel(red: 0xDB, green: 0xDB, blue: 0xDB, alpha: 128).hex == "#DBDBDB80") + } + + @Test("collapses an empty line into no runs") + func emptyLine() { + #expect(Pixels.runs(of: [], startingAt: 0).isEmpty) + } + + /// A screenshot is written in the display's profile, which is not the space a + /// palette constant was written in. Read raw, a `#DBDBDB` divider comes back + /// as something several steps off and looks like a bug in the app. + /// + /// The image here is tagged Display P3 and holds the P3 encoding of sRGB + /// `#DBDBDB`, so a reader that converts reports the value the palette names + /// and a reader that does not reports `#DBDBDB` itself — which is the wrong + /// answer, arrived at by leaving the numbers alone. + @Test("reports colours in sRGB whatever space the image is tagged with") + func convertsToSRGB() throws { + let p3 = try #require(CGColorSpace(name: CGColorSpace.displayP3)) + let sRGB = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let level = CGFloat(0xDB) / 255 + let grey = try #require( + CGColor(colorSpace: sRGB, components: [level, level, level, 1])) + let converted = try #require( + grey.converted(to: p3, intent: CGColorRenderingIntent.defaultIntent, options: nil)) + let parts = try #require(converted.components) + + let path = NSTemporaryDirectory() + "/jpdrive-p3-\(UUID().uuidString).png" + defer { try? FileManager.default.removeItem(atPath: path) } + + let bytes = parts.prefix(3).map { UInt8(($0 * 255).rounded()) } + [255] + let provider = try #require(CGDataProvider(data: Data(bytes) as CFData)) + let image = try #require( + CGImage( + width: 1, + height: 1, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: 4, + space: p3, + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + )) + + let url = URL(fileURLWithPath: path) as CFURL + let destination = try #require( + CGImageDestinationCreateWithURL(url, UTType.png.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, image, nil) + #expect(CGImageDestinationFinalize(destination)) + + let report = try Pixels.read( + PixelOptions(image: path, axis: .row, at: 0, from: nil, to: nil)) + + #expect(report.runs.first?.color == "#DBDBDB") + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ResizeTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ResizeTests.swift new file mode 100644 index 000000000..6240dcf08 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/ResizeTests.swift @@ -0,0 +1,103 @@ +import ApplicationServices +import Foundation +import Testing + +@testable import DriveKit + +/// Tests for the `resize` step. +/// +/// A window is the only thing that accepts a write to `AXSize`, and resizing is +/// the one interaction a driver cannot reach any other way: a drag of a window's +/// edge has to be synthesized, and a synthesized drag needs the window frontmost. +@Suite("Resize") +struct ResizeTests { + /// A window that accepts a size, starting at `size`. + private func window(_ size: CGSize, settable: Bool = true) -> FakeElement { + let window = FakeElement( + role: "AXWindow", + identifier: "the-window", + settable: settable ? [kAXSizeAttribute] : [] + ) + window.sizes[kAXSizeAttribute] = size + return window + } + + private func step(width: Double, height: Double) -> Step { + .resize(Step.SizeTarget(identifier: "the-window", width: width, height: height)) + } + + @Test("writes the size it was asked for") + func writesTheSize() throws { + let window = self.window(CGSize(width: 900, height: 450)) + + let result = try Act.run(step(width: 1400, height: 900), in: window) + + #expect(window.sizes[kAXSizeAttribute] == CGSize(width: 1400, height: 900)) + #expect(result.step == "resize") + #expect(result.role == "AXWindow") + #expect(result.confirmed == true) + #expect(result.size == "1400x900") + } + + /// A window clamps to its own minimum and maximum, so the write succeeds and + /// the window lands somewhere else. Reporting what it reached is the whole + /// reason the step reads the size back instead of echoing the request. + @Test("reports the size it reached when the window clamps the request") + func reportsAClampedSize() throws { + let window = self.window(CGSize(width: 900, height: 450)) + window.ignoresWrites = true + + let result = try Act.run(step(width: 200, height: 100), in: window) + + #expect(result.confirmed == false) + #expect(result.size == "900x450") + } + + /// Most elements inside a window do not accept a size, and a step that asked + /// anyway would report success having changed nothing. + @Test("refuses an element that does not accept a size") + func refusesAnUnsizableElement() { + let element = window(CGSize(width: 900, height: 450), settable: false) + + #expect(throws: DriveError.self) { + try Act.run(step(width: 1400, height: 900), in: element) + } + } + + @Test("reports an identifier that is not in the tree") + func reportsAMissingIdentifier() { + let other = FakeElement(role: "AXWindow", identifier: "something-else") + + #expect(throws: DriveError.self) { + try Act.run(step(width: 1400, height: 900), in: other) + } + } + + @Test("surfaces a write the accessibility API refused") + func surfacesARefusedWrite() { + let window = self.window(CGSize(width: 900, height: 450)) + window.writeStatus = .cannotComplete + + #expect(throws: DriveError.self) { + try Act.run(step(width: 1400, height: 900), in: window) + } + } + + /// The step arrives as JSON from the driver's caller, so the spelling of its + /// keys is part of the contract. + @Test("decodes the step a caller writes") + func decodesTheStep() throws { + let json = #"{"resize":{"identifier":"w","width":1400,"height":900}}"# + + let decoded = try JSONDecoder().decode(Step.self, from: Data(json.utf8)) + + guard case .resize(let target) = decoded else { + Issue.record("expected a resize step, got \(decoded)") + return + } + + #expect(target.identifier == "w") + #expect(target.width == 1400) + #expect(target.height == 900) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/TreeTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/TreeTests.swift new file mode 100644 index 000000000..fb3677812 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/TreeTests.swift @@ -0,0 +1,164 @@ +import Testing + +@testable import DriveKit + +@Suite("Tree") +struct TreeTests { + /// Options with the bounds wide open, so a test names only what it is about. + private func options( + prefix: String? = nil, + maxMatches: Int = 100, + maxDepth: Int = 20, + maxSiblings: Int = 0, + frames: Bool = false + ) -> TreeOptions { + return TreeOptions( + pid: 0, + identifierPrefix: prefix, + maxMatches: maxMatches, + maxDepth: maxDepth, + maxSiblings: maxSiblings, + frames: frames + ) + } + + @Test("an unfiltered walk keeps every element") + func keepsEverythingUnfiltered() throws { + let root = FakeElement.sidebar(rowCount: 2) + + let tree = try #require(Tree.walk(from: root, options: options())) + + #expect(tree.role == "AXApplication") + let outline = try #require(tree.children.first) + #expect(outline.identifier == "sidebar.list") + #expect(outline.children.count == 2) + } + + /// The bug this replaced: with a sibling cap in force, a search for a row past + /// the cap found nothing, because the cap dropped it before the filter saw it. + @Test("a filtered walk finds a match past the sibling cap") + func filterOutrunsTheSiblingCap() throws { + let root = FakeElement.sidebar(rowCount: 50) + + let tree = try #require( + Tree.walk(from: root, options: options(prefix: "sidebar.row.42", maxSiblings: 5)) + ) + + let leaf = tree.children.first?.children.first?.children.first?.children.first + #expect(leaf?.identifier == "sidebar.row.42") + } + + /// Ancestors are kept so a match can be located, but only the ones leading to it. + @Test("a filtered walk drops branches holding no match") + func prunesUnmatchedBranches() throws { + let root = FakeElement( + role: "AXApplication", + children: [ + FakeElement(role: "AXWindow", identifier: "other.window"), + FakeElement( + role: "AXOutline", + identifier: "sidebar.list", + children: [FakeElement(role: "AXRow", identifier: "sidebar.row.0")] + ), + ] + ) + + let tree = try #require(Tree.walk(from: root, options: options(prefix: "sidebar."))) + + #expect(tree.children.count == 1) + #expect(tree.children.first?.identifier == "sidebar.list") + + // The dropped window is still counted. A filtered read that silently + // showed one child of two would have the reader believe the application + // has one. + #expect(tree.elidedChildren == 1) + } + + @Test("a filtered walk with no match returns nothing") + func returnsNothingWhenNothingMatches() { + let root = FakeElement.sidebar(rowCount: 3) + + #expect(Tree.walk(from: root, options: options(prefix: "nope.")) == nil) + } + + /// The budget is the bound that keeps a prefix search off the whole tree, so it + /// has to actually stop the walk rather than only trim the output. + @Test("the match budget stops the walk") + func budgetStopsTheWalk() throws { + let root = FakeElement.sidebar(rowCount: 500) + + let tree = try #require( + Tree.walk(from: root, options: options(prefix: "sidebar.", maxMatches: 3)) + ) + + // One match is the outline itself, leaving two rows. + let outline = try #require(tree.children.first) + #expect(outline.children.count == 2) + + // Reads, not results: a budget that trimmed the output while still visiting + // every element would pass an assertion on the tree alone. + let rows = try #require(root.children.first?.children) + #expect(rows.dropFirst(3).allSatisfy { $0.reads == 0 }) + } + + @Test("the sibling cap reports what it skipped") + func capReportsElidedChildren() throws { + let root = FakeElement.sidebar(rowCount: 10) + + let tree = try #require(Tree.walk(from: root, options: options(maxSiblings: 4))) + + let outline = try #require(tree.children.first) + #expect(outline.children.count == 4) + #expect(outline.elidedChildren == 6) + } + + @Test("a complete level reports no elision") + func noElisionWhenComplete() throws { + let root = FakeElement.sidebar(rowCount: 3) + + let tree = try #require(Tree.walk(from: root, options: options(maxSiblings: 10))) + + #expect(tree.children.first?.elidedChildren == nil) + } + + /// The count is what separates a node stopped at the depth limit from a leaf. + /// Without it the two render identically and a reader concludes the element + /// has no children. + @Test("the depth limit stops the descent and reports what it did not reach") + func depthLimitStopsDescent() throws { + let root = FakeElement.sidebar(rowCount: 2) + + let tree = try #require(Tree.walk(from: root, options: options(maxDepth: 1))) + + #expect(tree.children.first?.children.isEmpty == true) + #expect(tree.children.first?.elidedChildren == 2) + } + + /// Frames move whenever a window moves or a list scrolls, so they stay out + /// unless asked for. + @Test("frames are omitted by default") + func framesAreOptIn() throws { + let root = FakeElement(role: "AXWindow") + root.attributes["AXFrame"] = "0.0,0.0 100.0x100.0" + + let without = try #require(Tree.walk(from: root, options: options())) + #expect(without.frame == nil) + + let with = try #require(Tree.walk(from: root, options: options(frames: true))) + #expect(with.frame == "0.0,0.0 100.0x100.0") + } + + /// An attribute the element does not report must arrive as absent, not as the + /// text of whatever error the accessibility API answered with. + @Test("absent attributes are absent, not error text") + func absentAttributesAreNull() throws { + let root = FakeElement(role: "AXCell") + + let tree = try #require(Tree.walk(from: root, options: options())) + + #expect(tree.identifier == nil) + #expect(tree.label == nil) + #expect(tree.value == nil) + #expect(tree.enabled == nil) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/TypeTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/TypeTests.swift new file mode 100644 index 000000000..436ce5ab2 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/TypeTests.swift @@ -0,0 +1,186 @@ +import ApplicationServices +import Testing + +@testable import DriveKit + +@Suite("Act.type") +struct TypeTests { + /// A text field shaped like the one SwiftUI produces: `AXValue` writable, + /// `AXPress` absent. + private func field(identifier: String = "sidebar.filter") -> FakeElement { + return FakeElement( + role: "AXTextField", + identifier: identifier, + settable: [kAXValueAttribute, kAXFocusedAttribute], + actions: ["AXShowMenu", "AXConfirm"] + ) + } + + @Test("writes the text into the field") + func writesTheText() throws { + let field = field() + let root = FakeElement(role: "AXApplication", children: [field]) + + let result = try Act.run( + .type(.init(identifier: "sidebar.filter", text: "driving")), + in: root + ) + + #expect(field.attributes[kAXValueAttribute] == "driving") + #expect(result.step == "type") + #expect(result.role == "AXTextField") + #expect(result.confirmed == true) + } + + /// Writing the value alone changes the text a `SwiftUI` field shows without the + /// binding behind it noticing, so the application carries on as though nothing + /// was typed. The confirm is what the application actually observes, and a + /// `type` that skipped it would report success having done nothing. + @Test("commits the edit through the field's confirm action") + func commitsTheEdit() throws { + let field = field() + let root = FakeElement(role: "AXApplication", children: [field]) + + let result = try Act.run( + .type(.init(identifier: "sidebar.filter", text: "driving")), + in: root + ) + + #expect(field.performed == ["AXConfirm"]) + #expect(result.committed == true) + } + + /// A field that publishes every change as it happens needs nothing committing, + /// so this is reported rather than treated as a failure. + @Test("reports a field with no confirm action as uncommitted") + func reportsAnUncommittedWrite() throws { + let field = FakeElement( + role: "AXTextField", + identifier: "live", + settable: [kAXValueAttribute], + actions: [] + ) + let root = FakeElement(role: "AXApplication", children: [field]) + + let result = try Act.run(.type(.init(identifier: "live", text: "x")), in: root) + + #expect(result.confirmed == true) + #expect(result.committed == false) + #expect(field.performed.isEmpty) + } + + /// Text in the field that the application never saw is the worst outcome to + /// report as success, so a refused confirm fails the step. + @Test("fails when the edit cannot be committed") + func failsOnARefusedConfirm() throws { + let field = field() + field.performStatus = .cannotComplete + let root = FakeElement(role: "AXApplication", children: [field]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.type(.init(identifier: "sidebar.filter", text: "x")), in: root) + } + + #expect(error.kind == .actionFailed) + #expect(error.hint?.contains("not committed") == true) + } + + /// Typing replaces rather than appends, so a script does not have to clear the + /// field first and a second step cannot silently concatenate. + @Test("replaces what the field already held") + func replacesExistingText() throws { + let field = field() + field.attributes[kAXValueAttribute] = "old" + let root = FakeElement(role: "AXApplication", children: [field]) + + _ = try Act.run(.type(.init(identifier: "sidebar.filter", text: "new")), in: root) + + #expect(field.attributes[kAXValueAttribute] == "new") + } + + /// Clearing is typing nothing, not a step of its own. + @Test("an empty string clears the field") + func clearsTheField() throws { + let field = field() + field.attributes[kAXValueAttribute] = "something" + let root = FakeElement(role: "AXApplication", children: [field]) + + let result = try Act.run(.type(.init(identifier: "sidebar.filter", text: "")), in: root) + + #expect(field.attributes[kAXValueAttribute] == "") + #expect(result.confirmed == true) + } + + /// A static label and a disabled field both resolve by identifier and both + /// refuse the write. Failing here beats reporting a write that went nowhere. + @Test("fails on an element whose value is not writable") + func failsOnAReadOnlyElement() throws { + let label = FakeElement(role: "AXStaticText", identifier: "subtitle") + let root = FakeElement(role: "AXApplication", children: [label]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.type(.init(identifier: "subtitle", text: "x")), in: root) + } + + #expect(error.kind == .notEditable) + #expect(label.attributes[kAXValueAttribute] == nil) + } + + @Test("reports a refused write") + func reportsARefusedWrite() throws { + let field = field() + field.writeStatus = .cannotComplete + let root = FakeElement(role: "AXApplication", children: [field]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.type(.init(identifier: "sidebar.filter", text: "x")), in: root) + } + + #expect(error.kind == .writeFailed) + #expect(error.message.contains("cannot_complete")) + } + + /// The accessibility API lets a target accept a write and discard it, which is + /// the whole reason the step reads back instead of trusting the status. + @Test("reports an accepted write that did not take") + func reportsAnIneffectiveWrite() throws { + let field = field() + field.ignoresWrites = true + let root = FakeElement(role: "AXApplication", children: [field]) + + let result = try Act.run( + .type(.init(identifier: "sidebar.filter", text: "driving")), + in: root + ) + + #expect( + result.confirmed == false, + "a field that discarded the text must not report as confirmed" + ) + } + + @Test("reports an identifier it could not find") + func reportsAMissingIdentifier() throws { + let root = FakeElement(role: "AXApplication", children: [field()]) + + let error = try #require(throws: DriveError.self) { + try Act.run(.type(.init(identifier: "nope", text: "x")), in: root) + } + + #expect(error.kind == .identifierNotFound) + } + + /// Only the addressed field is written to, so a step cannot quietly clobber a + /// second field that happens to sit nearby. + @Test("leaves other fields alone") + func leavesOtherFieldsAlone() throws { + let first = field(identifier: "one") + let second = field(identifier: "two") + let root = FakeElement(role: "AXApplication", children: [first, second]) + + _ = try Act.run(.type(.init(identifier: "two", text: "x")), in: root) + + #expect(first.attributes[kAXValueAttribute] == nil) + #expect(second.attributes[kAXValueAttribute] == "x") + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WaitForTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WaitForTests.swift new file mode 100644 index 000000000..7510632c2 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WaitForTests.swift @@ -0,0 +1,137 @@ +import Testing + +@testable import DriveKit + +@Suite("Act.waitFor") +struct WaitForTests { + /// A container that produces the awaited element on its third read, and not + /// before. + /// + /// The delay is what makes a wait test mean anything: against a tree that + /// already holds the element, polling and not polling look identical. + private func appearsOnThirdRead() -> FakeElement { + let container = FakeElement(role: "AXScrollArea", identifier: "transcript.scroll") + container.onRead = { element in + guard element.reads == 3 else { return } + element.children = [ + FakeElement(role: "AXGroup", identifier: "transcript.event.1") + ] + } + return container + } + + private func step( + _ identifier: String, + under: String? = nil, + timeoutMs: Int? = nil, + intervalMs: Int? = 1 + ) -> Step { + return .waitFor( + .init( + identifier: identifier, under: under, timeoutMs: timeoutMs, + intervalMs: intervalMs) + ) + } + + @Test("an element already present is returned on the first attempt") + func returnsImmediately() throws { + let root = FakeElement.sidebar(rowCount: 2) + + // A zero timeout permits exactly one attempt, so a pass here cannot have + // come from a retry. + let result = try Act.run(step("sidebar.row.1", timeoutMs: 0), in: root) + + #expect(result.step == "wait_for") + #expect(result.role == "AXUnknown") + #expect(result.confirmed == true) + } + + /// Half of a pair. This one proves the fixture genuinely withholds the element, + /// so that the passing case below is evidence of retrying rather than of the + /// element having been there all along. + @Test("one attempt is not enough for an element that appears later") + func oneAttemptIsNotEnough() throws { + let container = appearsOnThirdRead() + let root = FakeElement(role: "AXApplication", children: [container]) + + let error = try #require(throws: DriveError.self) { + try Act.run(step("transcript.event.1", timeoutMs: 0), in: root) + } + + #expect(error.kind == .timeout) + } + + @Test("polling finds an element that appears later") + func findsAnElementThatAppearsLater() throws { + let container = appearsOnThirdRead() + let root = FakeElement(role: "AXApplication", children: [container]) + + let result = try Act.run(step("transcript.event.1", timeoutMs: 2000), in: root) + + #expect(result.confirmed == true) + #expect(result.role == "AXGroup") + #expect( + container.reads >= 3, "the element cannot have been found before its third read") + } + + @Test("an element that never appears times out") + func timesOut() throws { + let root = FakeElement.sidebar(rowCount: 2) + + let error = try #require(throws: DriveError.self) { + try Act.run(step("never.appears", timeoutMs: 20), in: root) + } + + #expect(error.kind == .timeout) + #expect(error.message.contains("never.appears")) + } + + /// A single attempt eating the whole timeout is the failure mode that makes an + /// unscoped wait useless, so the error says what to do about it. + @Test("a timeout after one attempt suggests scoping") + func suggestsScopingAfterOneAttempt() throws { + let root = FakeElement.sidebar(rowCount: 2) + + let error = try #require(throws: DriveError.self) { + try Act.run(step("never.appears", timeoutMs: 0), in: root) + } + + #expect(error.hint?.contains("under") == true) + } + + /// Waiting inside something that does not exist is a mistake in the script, not + /// a condition that might come true. + @Test("a missing container fails at once rather than being waited for") + func missingContainerFailsFast() throws { + let root = FakeElement.sidebar(rowCount: 2) + + let error = try #require(throws: DriveError.self) { + try Act.run(step("anything", under: "no.such.container", timeoutMs: 5000), in: root) + } + + #expect(error.kind == .identifierNotFound) + } + + /// The reason `under` exists. Without it every attempt re-reads the whole + /// application, and against this app's sidebar one attempt outlasts a typical + /// timeout. + @Test("scoping keeps polling off the rest of the tree") + func scopingBoundsThePolling() throws { + let sidebar = FakeElement.sidebar(rowCount: 20) + let outline = try #require(sidebar.children.first) + let container = FakeElement(role: "AXScrollArea", identifier: "transcript.scroll") + let root = FakeElement(role: "AXApplication", children: [outline, container]) + + let error = try #require(throws: DriveError.self) { + try Act.run( + step("transcript.event.1", under: "transcript.scroll", timeoutMs: 30), + in: root + ) + } + #expect(error.kind == .timeout) + + // Read once while resolving the container, and never again. Repeated reads + // here would mean each poll was walking the sidebar. + #expect(outline.children.allSatisfy { $0.reads == 1 }) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowIDsTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowIDsTests.swift new file mode 100644 index 000000000..701110dc4 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowIDsTests.swift @@ -0,0 +1,129 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import DriveKit + +@Suite("WindowIDs") +struct WindowIDsTests { + /// One entry shaped the way the window server reports it: every number a + /// `CFNumber`, with the bounds arriving as doubles. + private func entry( + id: Int, + pid: Int, + layer: Int = 0, + title: String? = "JP", + width: Double = 1200, + height: Double = 800 + ) -> [String: Any] { + var window: [String: Any] = [ + kCGWindowNumber as String: id, + kCGWindowOwnerPID as String: pid, + kCGWindowLayer as String: layer, + kCGWindowBounds as String: ["X": 0.0, "Y": 0.0, "Width": width, "Height": height], + ] + window[kCGWindowName as String] = title + return window + } + + @Test("reports the window server's number and the window's size") + func reportsIdentifiers() { + let listed = [entry(id: 7412, pid: 4321)] + + #expect( + WindowIDs.capturable(from: listed, pid: 4321) == [ + CaptureWindow(id: 7412, title: "JP", width: 1200, height: 800) + ] + ) + } + + /// Every application on the desktop is in the list, so a capture that took the + /// first entry would photograph whatever happened to be frontmost. + @Test("keeps only the windows the pid owns") + func filtersByOwner() { + let listed = [ + entry(id: 1, pid: 999, title: "Terminal"), + entry(id: 2, pid: 4321), + entry(id: 3, pid: 111, title: "Finder"), + ] + + #expect(WindowIDs.capturable(from: listed, pid: 4321).map(\.id) == [2]) + } + + /// Tooltips, drag images and menu shadows are the app's too, and capturing one + /// in place of the window is a wrong answer rather than a failure. + @Test("keeps only windows on the normal layer") + func dropsChrome() { + let listed = [ + entry(id: 1, pid: 4321, layer: 25, title: "tooltip"), + entry(id: 2, pid: 4321), + ] + + #expect(WindowIDs.capturable(from: listed, pid: 4321).map(\.id) == [2]) + } + + /// A panel that has never been shown sits in the list at zero size, and + /// capturing it produces an empty file. + @Test("drops windows with no area") + func dropsEmptyWindows() { + let listed = [ + entry(id: 1, pid: 4321, width: 0, height: 0), + entry(id: 2, pid: 4321), + ] + + #expect(WindowIDs.capturable(from: listed, pid: 4321).map(\.id) == [2]) + } + + /// The window server withholds other applications' titles until the Screen + /// Recording grant is given, which is the state a first run is in. + @Test("a window with no readable title is still reported") + func toleratesAMissingTitle() { + let listed = [entry(id: 7412, pid: 4321, title: nil)] + + #expect( + WindowIDs.capturable(from: listed, pid: 4321) == [ + CaptureWindow(id: 7412, title: nil, width: 1200, height: 800) + ] + ) + } + + /// What actually arrives is a `CFArray` of `CFDictionary`, so every number in it + /// is an `NSNumber` once bridged, and a reader that only understood Swift's own + /// numeric types would report an application with no windows at all. + @Test("reads the numbers as the bridged types the window server hands over") + func readsBridgedNumbers() { + let listed: [[String: Any]] = [ + [ + kCGWindowNumber as String: NSNumber(value: 7412), + kCGWindowOwnerPID as String: NSNumber(value: 4321), + kCGWindowLayer as String: NSNumber(value: 0), + kCGWindowName as String: "JP", + kCGWindowBounds as String: [ + "X": NSNumber(value: 0.0), + "Y": NSNumber(value: 0.0), + "Width": NSNumber(value: 1200.0), + "Height": NSNumber(value: 800.0), + ], + ] + ] + + #expect( + WindowIDs.capturable(from: listed, pid: 4321) == [ + CaptureWindow(id: 7412, title: "JP", width: 1200, height: 800) + ] + ) + } + + /// Front-to-back is the window server's own order, and it is the only thing + /// telling a caller which of two windows to capture. + @Test("preserves the order the window server reported") + func preservesOrder() { + let listed = [ + entry(id: 3, pid: 4321), + entry(id: 1, pid: 4321), + entry(id: 2, pid: 4321), + ] + + #expect(WindowIDs.capturable(from: listed, pid: 4321).map(\.id) == [3, 1, 2]) + } +} diff --git a/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowsTests.swift b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowsTests.swift new file mode 100644 index 000000000..437fa98e0 --- /dev/null +++ b/apps/macos/Tools/jpdrive/Tests/DriveKitTests/WindowsTests.swift @@ -0,0 +1,61 @@ +import ApplicationServices +import Testing + +@testable import DriveKit + +@Suite("Windows") +struct WindowsTests { + @Test("reports each window's own facts") + func reportsWindowFacts() { + let main = FakeElement(role: "AXWindow", identifier: "workspace-AppWindow-1") + main.attributes[kAXTitleAttribute] = "JP" + main.attributes[kAXMainAttribute] = "1" + main.attributes[kAXMinimizedAttribute] = "0" + main.attributes["AXFrame"] = "0.0,0.0 1200.0x800.0" + + let other = FakeElement(role: "AXWindow", identifier: "workspace-AppWindow-2") + other.attributes[kAXMainAttribute] = "0" + other.attributes[kAXMinimizedAttribute] = "1" + + let app = FakeElement(role: "AXApplication") + app.related[kAXWindowsAttribute] = [main, other] + + #expect( + Windows.list(of: app) == [ + WindowSummary( + identifier: "workspace-AppWindow-1", + title: "JP", + main: true, + minimized: false, + frame: "0.0,0.0 1200.0x800.0" + ), + WindowSummary( + identifier: "workspace-AppWindow-2", + title: nil, + main: false, + minimized: true, + frame: nil + ), + ] + ) + } + + /// A running application with every window closed is a normal state, not an + /// error. + @Test("an application with no windows reports an empty list") + func noWindows() { + #expect(Windows.list(of: FakeElement(role: "AXApplication")).isEmpty) + } + + /// Windows are read from the application's own attribute, not found by walking + /// into the hierarchy. A listing that descended would pick up sheets and popups + /// as if they were windows. + @Test("windows are read from the attribute, not from the children") + func doesNotWalkChildren() { + let child = FakeElement(role: "AXWindow", identifier: "not.a.window") + let app = FakeElement(role: "AXApplication", children: [child]) + + #expect(Windows.list(of: app).isEmpty) + #expect(child.reads == 0) + } +} diff --git a/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/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: