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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable

- name: Install tools
run: brew install just xcodegen xcbeautify swiftlint
run: brew install just xcodegen xcbeautify swiftlint jj

- name: Cache cargo
uses: actions/cache@v5
Expand All @@ -69,3 +69,14 @@ jobs:

- name: Test app
run: just test-app

- name: UI tests
run: just test-ui

- name: Upload xcresult on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: ui-test-xcresult
path: build/DerivedData/Logs/Test/*.xcresult
if-no-files-found: warn
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ For JayJay specifically:
4. **Single Responsibility** — Each file/module does one thing. Each function has one job.
5. **Cross-platform core** — All business logic stays in Rust. Swift/platform code is only for rendering.
6. **Terse comments** — Code should be self-explanatory; comment only non-obvious *why*. When a comment is needed, one concise line. No multi-line doc blocks, no restating the code, no obvious-from-context commentary.
7. **Tested behavior** — Every new feature ships with both unit and UI tests. Rust unit tests (`just test`) cover core logic and ViewModel behavior; XCUITest scenes in `shell/mac/Tests/JayJayUITests/` (`just test-ui`) cover the user-visible flow. Bug fixes add the regression test that would have caught them.

## Testing

- `just test` — Rust unit tests across the workspace.
- `just test-app` — Swift unit tests (JayJayTests).
- `just test-ui` — XCUITest scenes against deterministic fixtures at `/tmp/jayjay-test-fixtures/{simple,conflict}`, built by `just shell::ui-test-setup`.

UI tests live in `shell/mac/Tests/JayJayUITests/`. Each `SceneBase` subclass launches the app against a named fixture (`simple` by default; override `fixtureName` for a different one) and asserts against accessibility identifiers declared in `Sources/JayJay/Shared/AccessibilityIdentifiers.swift`. Add identifiers at the view body, keyed by whatever data uniquely identifies the element (change-id prefix, file path, etc.).

If a scene **mutates repo state** (new change, abandon, rebase, Use Ours, ...), give it its own fixture — tests share a filesystem and run alphabetically, so mutations on `simple` leak into subsequent tests. `ui-test-setup` already produces `simple-newchange` as a copy for `NewChangeScene`; add a sibling copy for new mutating scenes.

## Architecture: MVVM

Expand Down
20 changes: 18 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,30 @@ This project uses [Jujutsu](https://github.com/jj-vcs/jj) for version control, n
## Development loop

```bash
just test # Run Rust tests
just test # Rust unit tests
just test-app # Swift unit tests
just test-ui # XCUITest scenes (builds fixtures, sets onboarding default)
just lint # Clippy + SwiftLint
just format # cargo fmt + SwiftFormat
just clean # Remove generated build artifacts
just build # Build the macOS app
just run # Build and run macOS app
```

## Testing

**Every new feature ships with both unit and UI test coverage.** Bug fixes add the regression test that would have caught them.

- **Rust unit tests** — cover core logic in `crates/jayjay-core/`. Run with `just test`.
- **Swift unit tests** — cover ViewModel-level behavior in `shell/mac/Tests/JayJayTests/`. Run with `just test-app`.
- **XCUITest scenes** — cover user-visible flows in `shell/mac/Tests/JayJayUITests/`. Run with `just test-ui`.

UI tests launch the app against deterministic fixtures at `/tmp/jayjay-test-fixtures/{simple,conflict}` built by `just shell::ui-test-setup`. Each scene subclasses `SceneBase` and asserts against accessibility identifiers declared in `shell/mac/Sources/JayJay/Shared/AccessibilityIdentifiers.swift`. When adding a new user-visible view or interaction:

1. Attach a stable `.accessibilityIdentifier(...)` to the view, keyed by the data that makes it unique (change-id prefix, file path, etc.). Add a constant/function to `AID` so tests and views share the same string.
2. Write a scene test under `Tests/JayJayUITests/Scenes/` that exercises the flow end-to-end.
3. If the scene needs fixture state that `simple`/`conflict` don't provide, extend `ui-test-setup` in `shell/justfile`.

## Architecture

```
Expand Down Expand Up @@ -78,7 +94,7 @@ When adding a feature:
When a feature lands:
- Update [README.md](README.md) if it changes what users can do today.
- Update [Roadmap.md](Roadmap.md) if it changes planned vs shipped status.
- Update this file if it changes architecture, contributor workflow, or the `jj-lib` vs `jj` CLI split.
- Update this file if it changes architecture, contributor workflow, the testing layout, or the `jj-lib` vs `jj` CLI split.

## Project reference

Expand Down
6 changes: 6 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ set unstable
set shell := ["bash", "-euo", "pipefail", "-c"]
set positional-arguments

root := justfile_directory()

mod shell

default:
Expand All @@ -11,6 +13,7 @@ list:
@echo "just list Show available commands"
@echo "just test Run Rust tests"
@echo "just test-app Run macOS app tests"
@echo "just test-ui Run macOS app UI tests (needs fixture — see shell/mac/Tests/JayJayUITests/Support/SceneBase.swift)"
@echo "just format Format Rust and Swift sources"
@echo "just lint Lint Rust (clippy) and Swift (swiftlint)"
@echo "just clean Remove generated build artifacts"
Expand All @@ -27,6 +30,9 @@ test:
test-app:
just shell::test

test-ui:
just shell::ui-test

build:
just shell::build

Expand Down
64 changes: 64 additions & 0 deletions shell/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,70 @@ test: ffi sync-icon
-derivedDataPath "{{derived_data}}" \
test | xcbeautify

# Build deterministic jj fixtures at /tmp/jayjay-test-fixtures and set the
# onboarding-complete user default so fresh machines don't show the welcome
# screen during tests.
ui-test-setup:
#!/usr/bin/env bash
set -euo pipefail
defaults write {{bundle_id}} jayjay.hasCompletedOnboarding -bool YES

root=/tmp/jayjay-test-fixtures
rm -rf "$root"
mkdir -p "$root"

# Simple: three commits + an active working copy with two new files.
# Clone once, then copy per-class for tests that mutate repo state so leaks
# don't bleed across scenes (NewChangeScene creates a new @).
jj git init --colocate "$root/simple"
(
cd "$root/simple"
echo "# Sample project" > README.md
jj describe -m "initial"
jj new -m "add hello"
echo "hello" > hello.txt
jj new -m "add feature"
echo "feature" > feature.txt
jj bookmark create main -r @
jj new
echo "wip 1" > wip1.txt
echo "wip 2" > wip2.txt
)
cp -R "$root/simple" "$root/simple-newchange"

# Conflict: @ is a rebased change that conflicts with main on file.txt
jj git init --colocate "$root/conflict"
(
cd "$root/conflict"
printf "line1\nline2\nline3\n" > file.txt
jj describe -m "base"
jj bookmark create main -r @
jj new -m "main: line 2 changed"
printf "line1\nline2 MAIN\nline3\n" > file.txt
jj bookmark set main -r @
jj new -r 'main-' -m "feature: line 2 differently"
printf "line1\nline2 FEATURE\nline3\n" > file.txt
jj rebase -r @ -d main
)

# Run the macOS UI test bundle. Pass a test-id to narrow the run, e.g.:
# just shell::ui-test JayJayUITests/CommandPaletteScene/testOpenAndSearch
ui-test test_id='': ffi sync-icon ui-test-setup
#!/usr/bin/env bash
set -euo pipefail
xcodegen --spec "{{project}}/project.yml" --project "{{project}}"
args=(
-project "{{project}}/JayJay.xcodeproj"
-scheme JayJayUITests
-configuration Debug
-sdk macosx
-derivedDataPath "{{derived_data}}"
)
if [[ -n "{{test_id}}" ]]; then
args+=(-only-testing:"{{test_id}}")
fi
xcodebuild "${args[@]}" test | xcbeautify

run repo='': build
@killall JayJay 2>/dev/null || true
@if [[ -n "{{repo}}" ]]; then \
Expand Down
2 changes: 2 additions & 0 deletions shell/mac/Sources/JayJay/Detail/DetailHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,12 @@ extension ChangeDetailView {
actions?.resolveUseOurs(rev: detail.info.changeId, path: path)
}
.buttonStyle(.bordered)
.accessibilityIdentifier(AID.Conflict.useOurs(path))
Button("Use Theirs") {
actions?.resolveUseTheirs(rev: detail.info.changeId, path: path)
}
.buttonStyle(.bordered)
.accessibilityIdentifier(AID.Conflict.useTheirs(path))
if let tool = appSettings.externalEditor.jjMergeTool {
Button("Resolve in \(appSettings.externalEditor.title)") {
actions?.resolveInEditor(rev: detail.info.changeId, path: path, tool: tool)
Expand Down
1 change: 1 addition & 0 deletions shell/mac/Sources/JayJay/Detail/FileColumn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ extension ChangeDetailView {
onToggleReview: { toggleReview(hunk.path) }
)
.contentShape(Rectangle())
.accessibilityIdentifier(AID.FileList.row(hunk.path))
.onTapGesture {
activePane = .fileColumn
NSApp.keyWindow?.makeFirstResponder(nil)
Expand Down
1 change: 1 addition & 0 deletions shell/mac/Sources/JayJay/Diff/DiffSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ struct DiffSection: View {
diffHeader
diffContent
}
.accessibilityIdentifier(AID.Diff.section)
.task(id: "\(compareFromRev ?? "")|\(rev ?? "")|\(hunk.path)|\(settings.ignoreWhitespace)") {
await computeDiffAsync()
}
Expand Down
4 changes: 3 additions & 1 deletion shell/mac/Sources/JayJay/Repo/DAGView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,13 @@ struct DAGView: View {
GeometryReader { geo in
Color.clear.preference(
key: DAGRebaseRowFramePreferenceKey.self,
value: [entry.change.commitId: geo.frame(in: .named(DAGRebaseCoordinateSpace.name))]
value: [entry.change.commitId: geo
.frame(in: .named(DAGRebaseCoordinateSpace.name))]
)
}
)
.id(entry.change.changeId)
.accessibilityIdentifier(AID.DAG.row(String(entry.change.changeId.prefix(12))))
.contentShape(Rectangle())
.onHover { hovering in
// Track right-click target via hover (context menu shows on hovered item)
Expand Down
33 changes: 33 additions & 0 deletions shell/mac/Sources/JayJay/Shared/AccessibilityIdentifiers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation

enum AID {
enum Palette {
static let textField = "commandPalette.searchField"
}

enum DAG {
static func row(_ changeIdPrefix: String) -> String {
"dag.row.\(changeIdPrefix)"
}
}

enum FileList {
static func row(_ path: String) -> String {
"file.row.\(path)"
}
}

enum Diff {
static let section = "diff.section"
}

enum Conflict {
static func useOurs(_ path: String) -> String {
"conflict.useOurs.\(path)"
}

static func useTheirs(_ path: String) -> String {
"conflict.useTheirs.\(path)"
}
}
}
1 change: 1 addition & 0 deletions shell/mac/Sources/JayJay/Shared/CommandPalette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ private struct PaletteRoot: View {
TextField("Type a command or ! for jj CLI...", text: $query)
.textFieldStyle(.plain)
.font(.system(size: 14))
.accessibilityIdentifier(AID.Palette.textField)
.onSubmit { execute() }
}
.padding(12)
Expand Down
20 changes: 20 additions & 0 deletions shell/mac/Tests/JayJayUITests/Scenes/AnnotateScene.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import XCTest

final class AnnotateScene: SceneBase {
func testRightClickAnnotate() throws {
let app = try XCTUnwrap(app)
let rows = dagRows(of: app)
XCTAssertTrue(rows.element(boundBy: 0).waitForExistence(timeout: 10), "DAG never populated")

// Parent of @ in the simple fixture is "add feature" — one modified file.
rows.element(boundBy: 1).click()

let fileRow = fileRows(of: app).element(boundBy: 0)
XCTAssertTrue(fileRow.waitForExistence(timeout: 5), "No file rows after selecting commit")

fileRow.rightClick()
let annotate = app.menuItems["Annotate (Blame)"]
XCTAssertTrue(annotate.waitForExistence(timeout: 3), "Annotate menu item missing")
annotate.click()
}
}
13 changes: 13 additions & 0 deletions shell/mac/Tests/JayJayUITests/Scenes/BookmarkManagerScene.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import XCTest

final class BookmarkManagerScene: SceneBase {
func testOpenViaShortcut() throws {
let app = try XCTUnwrap(app)
keyStroke("b", modifiers: [.command, .shift])

let title = app.staticTexts["Bookmark Manager"]
XCTAssertTrue(title.waitForExistence(timeout: 5), "Bookmark Manager did not open")

keyStroke(.escape)
}
}
16 changes: 16 additions & 0 deletions shell/mac/Tests/JayJayUITests/Scenes/CommandPaletteScene.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import XCTest

final class CommandPaletteScene: SceneBase {
func testOpenAndSearch() throws {
let app = try XCTUnwrap(app)
keyStroke("p", modifiers: [.command, .shift])

let field = app.textFields[AID.Palette.textField]
XCTAssertTrue(field.waitForExistence(timeout: 5), "Command palette did not open")

field.typeText("bookmark")
keyStroke(.downArrow)
keyStroke(.downArrow)
keyStroke(.escape)
}
}
14 changes: 14 additions & 0 deletions shell/mac/Tests/JayJayUITests/Scenes/ConflictResolutionScene.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import XCTest

final class ConflictResolutionScene: SceneBase {
override class var fixtureName: String { "conflict" }

func testUseOurs() throws {
let app = try XCTUnwrap(app)
let useOurs = app.buttons
.matching(NSPredicate(format: "identifier BEGINSWITH 'conflict.useOurs.'"))
.firstMatch
XCTAssertTrue(useOurs.waitForExistence(timeout: 10), "Expected conflict bar from fixture")
useOurs.click()
}
}
16 changes: 16 additions & 0 deletions shell/mac/Tests/JayJayUITests/Scenes/FileDiffScene.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import XCTest

final class FileDiffScene: SceneBase {
func testSelectFileShowsDiff() throws {
let app = try XCTUnwrap(app)
XCTAssertTrue(dagRows(of: app).element(boundBy: 0).waitForExistence(timeout: 10), "DAG never populated")

// @ in the simple fixture has wip1.txt — select it and expect the diff section to render.
let file = fileRows(of: app).element(boundBy: 0)
XCTAssertTrue(file.waitForExistence(timeout: 5), "No file rows in @")
file.click()

let diff = app.descendants(matching: .any)[AID.Diff.section]
XCTAssertTrue(diff.waitForExistence(timeout: 5), "Diff section did not appear")
}
}
14 changes: 14 additions & 0 deletions shell/mac/Tests/JayJayUITests/Scenes/InterdiffScene.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import XCTest

final class InterdiffScene: SceneBase {
func testShiftClickTwoRows() throws {
let app = try XCTUnwrap(app)
let rows = dagRows(of: app)
XCTAssertTrue(rows.element(boundBy: 0).waitForExistence(timeout: 10), "DAG never populated")

rows.element(boundBy: 0).click()
XCUIElement.perform(withKeyModifiers: .shift) {
rows.element(boundBy: 3).click()
}
}
}
28 changes: 28 additions & 0 deletions shell/mac/Tests/JayJayUITests/Scenes/NewChangeScene.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import XCTest

final class NewChangeScene: SceneBase {
// Dedicated fixture so the mutation doesn't leak into ReviewSplitScene etc.
override class var fixtureName: String { "simple-newchange" }

func testContextMenuNewChange() throws {
let app = try XCTUnwrap(app)
let rows = dagRows(of: app)
XCTAssertTrue(rows.element(boundBy: 0).waitForExistence(timeout: 10), "DAG never populated")

let originalTopId = rows.element(boundBy: 0).identifier

// Right-click the parent of @ and create a new change on top of it.
rows.element(boundBy: 1).rightClick()
let newChange = app.menuItems["New change on top"]
XCTAssertTrue(newChange.waitForExistence(timeout: 3), "\"New change on top\" menu item missing")
newChange.click()

// The new @ should have a different change-id than the previous top row.
let predicate = NSPredicate { _, _ in
let current = rows.element(boundBy: 0).identifier
return !current.isEmpty && current != originalTopId
}
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: nil)
XCTAssertEqual(XCTWaiter().wait(for: [expectation], timeout: 10), .completed, "@ did not change after new-change action")
}
}
Loading
Loading