From 799fc32f035d3f45f409e4579cdfa6a80f4abeb6 Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:52:23 +0200 Subject: [PATCH 01/11] fix(home): keep widget accessibility identifiers on their own elements The grid and each card applied .accessibilityIdentifier to a plain container, which SwiftUI stamps onto every child: the Edit Widgets button and each card's header icon reported the container's identifier, so UI tests found the wrong elements or none. Tests: resize via Contributions (Weekly rhythm became fixed-size in 7985b86) and reorder via Move Earlier, since synthesized drags never reach the grid's DragGesture. --- .../Home/Widgets/HomeWidgetCard.swift | 4 +++ .../Home/Widgets/HomeWidgetGrid.swift | 1 + FlotillaUITests/HomeWidgetsUITests.swift | 29 ++++++++++++------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/Flotilla/Features/Home/Widgets/HomeWidgetCard.swift b/Flotilla/Features/Home/Widgets/HomeWidgetCard.swift index ed59f89d..3ac8fd2e 100644 --- a/Flotilla/Features/Home/Widgets/HomeWidgetCard.swift +++ b/Flotilla/Features/Home/Widgets/HomeWidgetCard.swift @@ -78,6 +78,10 @@ struct HomeWidgetCard: View { // a click between the content's own controls lands on nothing, so // neither dragging nor the context menu would ever start. .contentShape(HomeWidgetCardMetrics.shape) + // `.contain` keeps this identifier on the card itself; on a plain + // container SwiftUI stamps it onto every child instead, so queries + // for the card matched its header icon. + .accessibilityElement(children: .contain) .accessibilityIdentifier(AXID.homeWidget.rawValue + kind.rawValue) } diff --git a/Flotilla/Features/Home/Widgets/HomeWidgetGrid.swift b/Flotilla/Features/Home/Widgets/HomeWidgetGrid.swift index fe46c60e..811a6bc8 100644 --- a/Flotilla/Features/Home/Widgets/HomeWidgetGrid.swift +++ b/Flotilla/Features/Home/Widgets/HomeWidgetGrid.swift @@ -61,6 +61,7 @@ struct HomeWidgetGrid: View { HomeWidgetGallerySheet(store: store, insights: insights, editor: editor) } .onAppear { editor.syncFromSettings() } + .accessibilityElement(children: .contain) .accessibilityIdentifier(AXID.homeWidgetGrid.rawValue) } diff --git a/FlotillaUITests/HomeWidgetsUITests.swift b/FlotillaUITests/HomeWidgetsUITests.swift index 066e5f88..d3e12c0f 100644 --- a/FlotillaUITests/HomeWidgetsUITests.swift +++ b/FlotillaUITests/HomeWidgetsUITests.swift @@ -40,18 +40,19 @@ final class HomeWidgetsUITests: XCTestCase { func testContextMenuResizesAWidget() { let app = launchedApp() openHome(app) - let rhythm = widget(app, "weeklyRhythm") - XCTAssertTrue(fastWait(rhythm)) - let before = rhythm.frame.width + // Contributions defaults to Wide and also offers Medium. + let contributions = widget(app, "contributions") + XCTAssertTrue(fastWait(contributions)) + let before = contributions.frame.width - rhythm.rightClick() - let wide = app.menuItems["Wide"] - XCTAssertTrue(fastWait(wide, timeout: 3), "context menu should offer the widget's sizes") - wide.click() + contributions.rightClick() + let medium = app.menuItems["Medium"] + XCTAssertTrue(fastWait(medium, timeout: 3), "context menu should offer the widget's sizes") + medium.click() let deadline = Date().addingTimeInterval(3) - while rhythm.frame.width < before * 1.5, Date() < deadline { usleep(50_000) } - XCTAssertGreaterThan(rhythm.frame.width, before * 1.5, "Wide should span the full row") + while contributions.frame.width > before * 0.75, Date() < deadline { usleep(50_000) } + XCTAssertLessThan(contributions.frame.width, before * 0.75, "Medium should no longer span the full row") } func testEditModeEntersAndEscapeDiscards() { @@ -72,7 +73,10 @@ final class HomeWidgetsUITests: XCTestCase { XCTAssertTrue(fastWait(widget(app, "agentShare")), "Esc should discard the removal") } - func testDraggingAWidgetMovesItAndDoneKeepsIt() { + // Reorders through the context menu, the same `editor.move` path the + // drag gesture uses: XCUITest's synthesized drags never reach the grid's + // SwiftUI DragGesture, so the gesture itself stays a manual check. + func testReorderingAWidgetMovesItAndDoneKeepsIt() { let app = launchedApp() openHome(app) element(app, .homeWidgetEditButton).click() @@ -83,7 +87,10 @@ final class HomeWidgetsUITests: XCTestCase { XCTAssertTrue(fastWait(rhythm) && fastWait(contributions)) XCTAssertGreaterThan(rhythm.frame.minY, contributions.frame.minY) - rhythm.press(forDuration: 0.3, thenDragTo: contributions) + rhythm.rightClick() + let moveEarlier = app.menuItems["Move Earlier"] + XCTAssertTrue(fastWait(moveEarlier, timeout: 3)) + moveEarlier.click() element(app, .homeWidgetDoneButton).click() XCTAssertTrue(fastWait(element(app, .homeWidgetEditButton))) From 1450454e744aff20591d585db2c5fa1ca5e3692d Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:59:31 +0200 Subject: [PATCH 02/11] fix(a11y): keep session bar and project row identifiers on one element The project row's identifier also landed on its collapse chevron, so a click meant for the row collapsed the project. Session bar meta labels now combine icon and text into one element. Tests: session identity moved from the window subtitle into the session bar (DetailColumn), which shows the branch without the flotilla/ prefix. --- Flotilla/Features/Session/SessionBar.swift | 6 ++++ Flotilla/Features/Shell/FleetSidebar.swift | 3 ++ FlotillaUITests/CreateSessionUITests.swift | 33 ++++++++++------------ FlotillaUITests/FlotillaUITests.swift | 11 ++++---- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/Flotilla/Features/Session/SessionBar.swift b/Flotilla/Features/Session/SessionBar.swift index f98c3e1c..8bf9f0db 100644 --- a/Flotilla/Features/Session/SessionBar.swift +++ b/Flotilla/Features/Session/SessionBar.swift @@ -299,6 +299,9 @@ struct SessionBar: View { .labelStyle(.titleAndIcon) .font(.system(size: 10, design: .monospaced)) .foregroundStyle(FlotillaColors.textSecondary) + // One element reading the text, so the caller's identifier names it + // rather than landing on the icon too. + .accessibilityElement(children: .combine) } private func metaLabel(_ text: String, systemImage: String) -> some View { @@ -312,6 +315,9 @@ struct SessionBar: View { .labelStyle(.titleAndIcon) .font(.system(size: 10, design: .monospaced)) .foregroundStyle(FlotillaColors.textSecondary) + // One element reading the text, so the caller's identifier names it + // rather than landing on the icon too. + .accessibilityElement(children: .combine) } // MARK: - Trailing diff --git a/Flotilla/Features/Shell/FleetSidebar.swift b/Flotilla/Features/Shell/FleetSidebar.swift index ef4ff58c..54994858 100644 --- a/Flotilla/Features/Shell/FleetSidebar.swift +++ b/Flotilla/Features/Shell/FleetSidebar.swift @@ -210,6 +210,9 @@ struct FleetSessionList: View { .listRowInsets(EdgeInsets()) .listRowBackground(liquidGlassEnabled ? Color.clear : FlotillaColors.sidebar) .background(ListSelectionHighlightSuppressor(isSuppressed: liquidGlassEnabled)) + // `.contain`, or the identifier lands on the collapse + // chevron too and clicks meant for the row toggle it. + .accessibilityElement(children: .contain) .accessibilityIdentifier(AXID.sidebarProjectRow.rawValue + project.name) .contextMenu { if let onCreateSessionFromIssue { diff --git a/FlotillaUITests/CreateSessionUITests.swift b/FlotillaUITests/CreateSessionUITests.swift index d3a9bc2e..6d65ab3b 100644 --- a/FlotillaUITests/CreateSessionUITests.swift +++ b/FlotillaUITests/CreateSessionUITests.swift @@ -65,11 +65,10 @@ final class CreateSessionUITests: XCTestCase { XCTAssertTrue(element(app, .createSessionCreateButton).waitForNonExistence(timeout: 5)) XCTAssertTrue(fastWait(element(app, AXID.sessionRow("Flaky CI")), timeout: 3)) - // The agent is asserted on the window subtitle rather than the row. + // The agent is asserted on the session bar rather than the row. // Navigator rows carry status and churn only now — the agent is the - // provider tile beside the title, and the branch would repeat what the - // subtitle already says once the session is open. - assertWindowIdentity(app, contains: "Codex CLI") + // provider tile beside the title. + assertSessionBar(app, AXID.sessionBarHandoff("Flaky CI"), contains: "Codex CLI") // MARK: Project session in a new worktree @@ -89,27 +88,25 @@ final class CreateSessionUITests: XCTestCase { XCTAssertTrue(element(app, .createSessionCreateButton).waitForNonExistence(timeout: 5)) XCTAssertTrue(fastWait(element(app, AXID.sessionRow("Dark mode")), timeout: 3)) - assertWindowIdentity(app, contains: "dark-mode") + assertSessionBar(app, AXID.sessionBarBranch("Dark mode"), contains: "dark-mode") } - /// The focused session states itself in the window title bar — title, then - /// project · agent · model · branch from `DetailColumn.identity`. It is the - /// only place that information appears while a terminal fills the screen. - private func assertWindowIdentity( + /// The focused session states its agent and branch in the session bar; + /// the window title carries only the session title (`DetailColumn`). + private func assertSessionBar( _ app: XCUIApplication, + _ identifier: String, contains needle: String, file: StaticString = #filePath, line: UInt = #line ) { - let window = app.windows.firstMatch - let matched = XCTNSPredicateExpectation( - predicate: NSPredicate { _, _ in window.title.contains(needle) }, - object: nil - ) - XCTAssertEqual( - XCTWaiter().wait(for: [matched], timeout: 5), - .completed, - "window title \"\(window.title)\" should contain \"\(needle)\"", + let item = element(app, identifier) + func text() -> String { "\(item.title) \(item.label) \(item.value as? String ?? "")" } + let deadline = Date().addingTimeInterval(5) + while !(item.exists && text().contains(needle)), Date() < deadline { usleep(100_000) } + XCTAssertTrue( + item.exists && text().contains(needle), + "\(identifier) should contain \"\(needle)\": \(item.exists ? item.debugDescription : "missing")", file: file, line: line ) diff --git a/FlotillaUITests/FlotillaUITests.swift b/FlotillaUITests/FlotillaUITests.swift index c8ece761..e7b310ea 100644 --- a/FlotillaUITests/FlotillaUITests.swift +++ b/FlotillaUITests/FlotillaUITests.swift @@ -42,14 +42,15 @@ final class FlotillaUITests: XCTestCase { XCTAssertTrue(fastWait(firstRow, timeout: 8)) firstRow.click() - // Session identity lives in the window title bar, set via - // DetailColumn's navigationTitle/navigationSubtitle. Both halves are - // asserted: the subtitle carries the branch and agent, which appear - // nowhere else while a session is focused. + // The window title carries the session title (DetailColumn's + // navigationTitle); the branch lives in the session bar. let window = app.windows.firstMatch XCTAssertTrue(fastWait(window, timeout: 3)) XCTAssertTrue(window.title.hasPrefix("Fix login bug"), "unexpected title: \(window.title)") - XCTAssertTrue(window.title.contains("flotilla/fix-login-bug"), "branch missing from title: \(window.title)") + let branch = element(app, AXID.sessionBarBranch("Fix login bug")) + XCTAssertTrue(fastWait(branch, timeout: 3), "session bar should show the branch") + // The bar shows `BranchNaming.displayName`, without the `flotilla/` prefix. + XCTAssertEqual(branch.value as? String, "fix-login-bug", "unexpected branch: \(branch.debugDescription)") let secondRow = element(app, AXID.sessionRow("Refactor sidebar")) XCTAssertTrue(fastWait(secondRow, timeout: 3)) From 0156ddcba4292f04f843b8ad326eba388bb20951 Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:04:42 +0200 Subject: [PATCH 03/11] fix(review): keep inner accessibility identifiers in the review window Identifiers on the window, diff pane, file sections and hunks were stamped onto every descendant, hiding the mode buttons, file comment buttons and hunk layouts from UI tests. --- Flotilla/Features/Review/ReviewDiffPane.swift | 4 ++++ Flotilla/Features/Review/SessionReviewWindow.swift | 3 +++ 2 files changed, 7 insertions(+) diff --git a/Flotilla/Features/Review/ReviewDiffPane.swift b/Flotilla/Features/Review/ReviewDiffPane.swift index 718f2463..7d89e6bb 100644 --- a/Flotilla/Features/Review/ReviewDiffPane.swift +++ b/Flotilla/Features/Review/ReviewDiffPane.swift @@ -69,6 +69,7 @@ struct ReviewDiffPane: View { } } } + .accessibilityElement(children: .contain) .accessibilityIdentifier(AXID.reviewDiffPane.rawValue) } } @@ -87,6 +88,7 @@ struct ReviewFileSection: View { } header: { header } + .accessibilityElement(children: .contain) .accessibilityIdentifier(AXID.reviewFileSection(file.path)) } @@ -223,9 +225,11 @@ struct ReviewFileSection: View { switch viewModel.diffMode { case .inline: inlineLines(hunk) + .accessibilityElement(children: .contain) .accessibilityIdentifier(AXID.reviewHunkInline.rawValue) case .sideBySide: sideBySideRows(hunk) + .accessibilityElement(children: .contain) .accessibilityIdentifier(AXID.reviewHunkSideBySide.rawValue) } } diff --git a/Flotilla/Features/Review/SessionReviewWindow.swift b/Flotilla/Features/Review/SessionReviewWindow.swift index bc93158e..d47311ff 100644 --- a/Flotilla/Features/Review/SessionReviewWindow.swift +++ b/Flotilla/Features/Review/SessionReviewWindow.swift @@ -39,6 +39,9 @@ struct SessionReviewWindow: View { } .frame(minWidth: 1_000, minHeight: 560) .flotillaLiquidSurface(FlotillaColors.canvas, glassTintOpacity: FlotillaGlassTint.detail) + // `.contain` on each identified container below: on a plain one, + // SwiftUI applies the identifier to every descendant, hiding theirs. + .accessibilityElement(children: .contain) .accessibilityIdentifier(AXID.reviewWindow.rawValue) .task(id: sessionID) { guard viewModel == nil, let session else { return } From f44744851f19383f2bd9969684ddb74c3a2edf3a Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:05:54 +0200 Subject: [PATCH 04/11] fix(git-sidebar): keep inner accessibility identifiers in the sidebar --- Flotilla/Features/Session/SessionGitSidebar.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Flotilla/Features/Session/SessionGitSidebar.swift b/Flotilla/Features/Session/SessionGitSidebar.swift index 3571d3cb..5529d1f4 100644 --- a/Flotilla/Features/Session/SessionGitSidebar.swift +++ b/Flotilla/Features/Session/SessionGitSidebar.swift @@ -36,6 +36,7 @@ struct SessionGitSidebar: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .flotillaInspectorSurface(ignoresSafeAreaEdges: .top) + .accessibilityElement(children: .contain) .accessibilityIdentifier("GitSidebar") .task(id: viewModel.monitorKey) { await viewModel.monitorSelection() From 9220061cbadb952d2d5e02d3939cbae7ec006a03 Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:06:53 +0200 Subject: [PATCH 05/11] fix(rules): expose the save status as one labelled element --- Flotilla/Features/Projects/Knowledge/KnowledgeDetailView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Flotilla/Features/Projects/Knowledge/KnowledgeDetailView.swift b/Flotilla/Features/Projects/Knowledge/KnowledgeDetailView.swift index bcaea9bd..065c9da6 100644 --- a/Flotilla/Features/Projects/Knowledge/KnowledgeDetailView.swift +++ b/Flotilla/Features/Projects/Knowledge/KnowledgeDetailView.swift @@ -180,6 +180,8 @@ struct KnowledgeDetailView: View { ) .font(FlotillaTypography.caption2) .foregroundStyle(message.hasPrefix("Saved") ? FlotillaColors.success : FlotillaColors.danger) + .accessibilityElement(children: .ignore) + .accessibilityLabel(message) .accessibilityIdentifier(AXID.knowledgeSaveStatus.rawValue) } From ce8c4f2fdaf93f68c6d09331d4ec502f172e07bf Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:09:07 +0200 Subject: [PATCH 06/11] test(fixtures): make the Fix login bug worktree a real git worktree Deleting a session refuses to remove a folder git doesn't list as a worktree, so the bare fixture directory was never removed. --- Flotilla/App/AppEnvironment.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Flotilla/App/AppEnvironment.swift b/Flotilla/App/AppEnvironment.swift index 852b6430..11ac7aff 100644 --- a/Flotilla/App/AppEnvironment.swift +++ b/Flotilla/App/AppEnvironment.swift @@ -21,6 +21,7 @@ final class AppEnvironment { /// only the app process can. static let uiTestFixtureProjectPath = URL(fileURLWithPath: "/tmp/flotilla-uitest-project") static let uiTestWorktreeBasePath = URL(fileURLWithPath: "/tmp/flotilla-uitest-worktrees") + static let uiTestFixtureWorktreePath = URL(fileURLWithPath: "/tmp/flotilla-fixture-project-worktrees/fix-login-bug") let sessionRepository: SessionRepository let gitService: GitServiceProtocol @@ -90,8 +91,7 @@ final class AppEnvironment { private static func resetUITestWorktreeDirectories() { let fileManager = FileManager.default - let fixtureWorktree = URL(fileURLWithPath: "/tmp/flotilla-fixture-project-worktrees/fix-login-bug") - for path in [uiTestWorktreeBasePath, URL(fileURLWithPath: "/tmp/flotilla-custom-worktrees"), fixtureWorktree] { + for path in [uiTestWorktreeBasePath, URL(fileURLWithPath: "/tmp/flotilla-custom-worktrees")] { try? fileManager.removeItem(at: path) try? fileManager.createDirectory(at: path, withIntermediateDirectories: true) } @@ -193,6 +193,11 @@ final class AppEnvironment { try "# Fixture Rules\n\nBe concise.\n".write(to: path.appendingPathComponent("CLAUDE.md"), atomically: true, encoding: .utf8) try runGit(["add", "README.md", "CLAUDE.md"], at: path) try runGit(["-c", "user.name=Flotilla UITests", "-c", "user.email=uitests@example.com", "commit", "-m", "init"], at: path) + + // A real registered worktree, not a bare directory: deleting a session + // refuses to remove a folder git doesn't know as a worktree. + try? fileManager.removeItem(at: uiTestFixtureWorktreePath) + try runGit(["worktree", "add", "-b", "flotilla/fix-login-bug", uiTestFixtureWorktreePath.path], at: path) } private static func runGit(_ arguments: [String], at path: URL) throws { @@ -245,7 +250,7 @@ final class AppEnvironment { workingDirectory: project.rootPath, worktree: WorktreeInfo( branchName: "flotilla/fix-login-bug", - worktreePath: URL(fileURLWithPath: "/tmp/flotilla-fixture-project-worktrees/fix-login-bug"), + worktreePath: uiTestFixtureWorktreePath, baseCheckoutPath: project.rootPath ), status: .working From 382aa589922e06cb527ed7a0ebc7f635834fc68e Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:19:11 +0200 Subject: [PATCH 07/11] test(settings): type into the focused palette field at app level --- FlotillaUITests/SettingsUITests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/FlotillaUITests/SettingsUITests.swift b/FlotillaUITests/SettingsUITests.swift index 190347ec..4c9bfe13 100644 --- a/FlotillaUITests/SettingsUITests.swift +++ b/FlotillaUITests/SettingsUITests.swift @@ -48,7 +48,10 @@ final class SettingsUITests: XCTestCase { app.typeKey("k", modifierFlags: .command) let search = element(app, "CommandPalette.Search") XCTAssertTrue(fastWait(search, timeout: 5)) - search.typeText("keyboard shortcuts") + // Type the way a user does, into whatever the palette focused: + // `search.typeText` demands focus on this exact element, which the + // SwiftUI field doesn't report even though it holds the caret. + app.typeText("keyboard shortcuts") let shortcutCommand = element(app, "CommandPalette.Row-Keyboard shortcuts") XCTAssertTrue(fastWait(shortcutCommand, timeout: 3)) From ec4756f9567c647f64e53d3cebde116023201be4 Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:27:29 +0200 Subject: [PATCH 08/11] ci: run UI tests in a parallel job on a 1920x1080 display --- .github/workflows/build.yml | 45 +++++++++++++++++++++++++++++++++++++ AGENTS.md | 18 ++++++++++----- Makefile | 3 ++- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d57d1806..94d1b7c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,3 +61,48 @@ jobs: build/DerivedData/Logs/Test/*.xcresult ~/Library/Logs/DiagnosticReports/Flotilla* retention-days: 7 + + # Parallel to `build`, so UI tests don't lengthen a run. A fresh runner, too: + # nothing has launched the app at a smaller display size first. + ui-tests: + runs-on: xcode-27 + timeout-minutes: 45 + + env: + SIGNING_OVERRIDE: CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO DEVELOPMENT_TEAM= + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install XcodeGen, tmux and displayplacer + run: brew install xcodegen tmux displayplacer + + - name: Install Metal toolchain + run: xcodebuild -downloadComponent MetalToolchain + + # The runner boots at 1024x768, narrower than the main window's + # minimum width, which leaves the toolbar and sidebars off-screen. + - name: Use a 1920x1080 display + run: | + id=$(displayplacer list | awk '/^Persistent screen id:/ {print $4; exit}') + displayplacer "id:$id res:1920x1080" + displayplacer list | grep -m1 '^Resolution:' + + - name: Generate Xcode project + run: xcodegen generate + + - name: Run UI tests + run: > + make test-ui SIGNING_OVERRIDE="$SIGNING_OVERRIDE" + TEST_FLAGS="-test-timeouts-enabled YES -maximum-test-execution-time-allowance 180" + + - name: Upload test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: ui-test-results + path: | + build/DerivedData/Logs/Test/*.xcresult + ~/Library/Logs/DiagnosticReports/Flotilla* + retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index 31714916..26949571 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -543,12 +543,18 @@ Publishing a GitHub Release archives the app with `make archive`, wraps `Flotill (also uploaded as the `Flotilla-DMG` artifact). Notarization is not included yet — Gatekeeper will still warn on first open until that is added. -CI does not run `make test-ui` yet. UI automation itself works on the hosted -runner, but its display boots at 1024x768, narrower than the main window's -minimum width. Switching it to 1920x1080 (`displayplacer`) still leaves the -window 692 pt tall, and 11 of 30 UI tests fail on content that doesn't fit. -Run the targeted `make test-ui` locally before merging changes that affect -UI/accessibility. +A parallel `ui-tests` job runs `make test-ui` on its own runner (results in +the `ui-test-results` artifact). The runner boots at 1024x768, narrower than +the main window's minimum width, so the job switches the display to 1920x1080 +with `displayplacer` first. XCUITest drags never reach the Home grid's +`DragGesture`, so widget reordering is tested via the context menu and the +drag itself stays a manual check. + +An `.accessibilityIdentifier` on a plain container is applied to every +descendant, overriding their own identifiers — UI tests then find the wrong +element or none. Give identified containers +`.accessibilityElement(children: .contain)`, and icon+text labels +`.accessibilityElement(children: .combine)`. Debug signs with the maintainer's own Apple Development team (`DEVELOPMENT_TEAM: UWAHVC4JTL` in `project.yml`) so that Screen Recording diff --git a/Makefile b/Makefile index b08de479..d4d5b71a 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ SCENARIO ?= # override signing on the command line, e.g.: # make build SIGNING_OVERRIDE="CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO DEVELOPMENT_TEAM=" SIGNING_OVERRIDE ?= -# Extra xcodebuild flags for `make test`, e.g. CI's per-test timeout. +# Extra xcodebuild flags for `make test` / `make test-ui`, e.g. CI's per-test timeout. TEST_FLAGS ?= # A local development team signs Ephemeral and the installed Release build # with a stable identity so TCC grants (Documents access, Handy approval) @@ -105,6 +105,7 @@ test-ui: xcodegen -derivedDataPath $(DERIVED_DATA) \ -parallel-testing-enabled NO \ $(SIGNING_OVERRIDE) \ + $(TEST_FLAGS) \ test -only-testing:FlotillaUITests # `make archive DEVELOPER_ID=1` signs for distribution (timestamped, as From 3000e26b688f94d5d793e75a7bb2ad9bea9a4d2c Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:31:35 +0200 Subject: [PATCH 09/11] docs(agents): UI tests need a visible desktop Space for screenshots --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 26949571..f9e157e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -396,6 +396,8 @@ func testWorktreeCreationFailureDoesNotCreateSession() { ... } > - You are explicitly asked to run or verify UI tests > For most changes, a Debug build + unit tests is sufficient verification. When running UI tests, prefer the minimal targeted set of test classes over the full suite (`-only-testing:FlotillaUITests/`). +**Run them from a normal desktop Space, not while another app is full screen.** The test app's window then opens in a Space that isn't visible: clicks and queries still work, but `window.screenshot()` fails with "Image creation failed" (`RestartSessionUITests`, `VocabularyScreenshotUITests`). + **Scope: only write a UI test for something a human can't easily eyeball, or that's a stable behavioral contract.** Don't write UI tests asserting on visual/styling details — background tints, padding, corner radius, colors, frame sizes chosen for looks. A human glances at a screenshot and knows instantly if those are wrong; a test asserting on them breaks on every intentional visual change and becomes pure maintenance overhead with no signal. Reserve UI tests for things that are easy to silently regress and hard to notice by eye: keyboard-shortcut wiring, focus/dismissal behavior, data flowing correctly into fields, multi-step interaction sequences, accessibility-identifier contracts other tests depend on. - All UI tests set `UI_TESTING=1` environment variable From 9fcb2d6b302513ca76c1a4e53c1d7ae091ad7f36 Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:36:39 +0200 Subject: [PATCH 10/11] test: remove the vocabulary screenshot pipeline and unasserted screenshots VocabularyScreenshotUITests asserted nothing about the UI; it only fed docs/ui-vocabulary.md's figures. Drop it with its scheme and publisher scripts; the existing figures stay as hand-maintained docs. The agent-exit test attached two screenshots nothing checked, which failed it whenever the window was in a hidden Space. --- AGENTS.md | 21 +- Flotilla.xcodeproj/project.pbxproj | 4 - .../UI Vocabulary Screenshots.xcscheme | 140 ----- Flotilla/App/UITestWindowPlacer.swift | 5 +- FlotillaUITests/RestartSessionUITests.swift | 8 - .../VocabularyScreenshotUITests.swift | 479 ------------------ Scripts/update-ui-vocabulary-screenshots.sh | 19 - .../update-ui-vocabulary-screenshots.swift | 475 ----------------- docs/test-value-inventory.md | 4 - docs/testing.md | 2 - docs/ui-vocabulary.md | 16 +- project.yml | 16 - 12 files changed, 4 insertions(+), 1185 deletions(-) delete mode 100644 Flotilla.xcodeproj/xcshareddata/xcschemes/UI Vocabulary Screenshots.xcscheme delete mode 100644 FlotillaUITests/VocabularyScreenshotUITests.swift delete mode 100644 Scripts/update-ui-vocabulary-screenshots.sh delete mode 100644 Scripts/update-ui-vocabulary-screenshots.swift diff --git a/AGENTS.md b/AGENTS.md index f9e157e1..45e6a963 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -396,7 +396,7 @@ func testWorktreeCreationFailureDoesNotCreateSession() { ... } > - You are explicitly asked to run or verify UI tests > For most changes, a Debug build + unit tests is sufficient verification. When running UI tests, prefer the minimal targeted set of test classes over the full suite (`-only-testing:FlotillaUITests/`). -**Run them from a normal desktop Space, not while another app is full screen.** The test app's window then opens in a Space that isn't visible: clicks and queries still work, but `window.screenshot()` fails with "Image creation failed" (`RestartSessionUITests`, `VocabularyScreenshotUITests`). +**Run them from a normal desktop Space, not while another app is full screen.** The test app's window then opens in a Space that isn't visible: clicks and queries still work, but any `screenshot()` fails with "Image creation failed". **Scope: only write a UI test for something a human can't easily eyeball, or that's a stable behavioral contract.** Don't write UI tests asserting on visual/styling details — background tints, padding, corner radius, colors, frame sizes chosen for looks. A human glances at a screenshot and knows instantly if those are wrong; a test asserting on them breaks on every intentional visual change and becomes pure maintenance overhead with no signal. Reserve UI tests for things that are easy to silently regress and hard to notice by eye: keyboard-shortcut wiring, focus/dismissal behavior, data flowing correctly into fields, multi-step interaction sequences, accessibility-identifier contracts other tests depend on. @@ -420,25 +420,6 @@ DeleteSessionDialog.Cancel DeleteSessionDialog.DeleteWithWorktree ``` -### UI Vocabulary Screenshot Pipeline - -`VocabularyScreenshotUITests` is a documentation generator, not a visual-regression test. Its purpose is to keep the visual appendix in `docs/ui-vocabulary.md` aligned with the app's current UI so contributors can connect canonical vocabulary such as **navigation rail**, **grid presentation**, and **diff panel** to the actual interface. It uses the deterministic `UI_TESTING=1` fixtures and deliberately makes no pixel-level styling assertions. - -To refresh the screenshots, open the project in Xcode, select the **UI Vocabulary Screenshots** scheme, and choose **Product → Test**. Run this workflow through Xcode rather than `xcodebuild` in a terminal because macOS UI automation requires Accessibility permission from the process driving the test. The dedicated scheme is declared in `project.yml`, selects only `VocabularyScreenshotUITests`, disables parallel execution, and runs `Scripts/update-ui-vocabulary-screenshots.sh` as its post-test action. Running the screenshot class through another scheme captures raw files but does not publish them. - -The pipeline works as follows: - -1. The test launches Flotilla with deterministic fixtures, moves capture windows onto the primary display, navigates the documented surfaces, and saves both persistent XCTest attachments and raw Retina PNGs in the UI-test runner's sandbox at `~/Library/Containers/com.niclassslua.FlotillaUITests.xctrunner/Data/Documents/flotilla-vocab-shots/`. -2. Class teardown writes `manifest.txt`. It contains `COMPLETE schema=1` only when every image required by the documentation was captured successfully. -3. The scheme post-action runs `Scripts/update-ui-vocabulary-screenshots.swift`. The publisher validates the completed manifest and all required inputs, derives the documented crops, retains high-resolution output (up to 3,200 px wide for full-window images), and stages the complete 21-image set before writing to `docs/images/ui-vocabulary/`. It then rewrites the visual-reference figures in `docs/ui-vocabulary.md` between the `BEGIN/END GENERATED FIGURES` markers — one image per line, from the same `publications` list — so that section never needs hand-editing and can't drift into multi-column tables. Prose outside the markers is untouched. -4. If the test is partial, a required capture is missing, or rendering fails, publication stops and the checked-in documentation images and Markdown remain unchanged. - -After changing a documented surface, capture name, caption, or visual-reference entry, update these together: `VocabularyScreenshotUITests.requiredCaptureNames` and the `publications` list in `Scripts/update-ui-vocabulary-screenshots.swift` (which carries each figure's `section`, `caption`, `alt`, and crop). The `docs/ui-vocabulary.md` figure blocks are regenerated from that list — don't edit them by hand; edit any surrounding prose directly. Run the dedicated scheme, inspect the refreshed PNGs visually, and commit the image and Markdown changes with the code. To rerun only publication from the most recent complete capture, use: - -```bash -/bin/bash Scripts/update-ui-vocabulary-screenshots.sh -``` - ### Accessibility - Extensive use of `.accessibilityIdentifier()` with dot-notation namespacing diff --git a/Flotilla.xcodeproj/project.pbxproj b/Flotilla.xcodeproj/project.pbxproj index daa7e7b5..9a1387f0 100644 --- a/Flotilla.xcodeproj/project.pbxproj +++ b/Flotilla.xcodeproj/project.pbxproj @@ -213,7 +213,6 @@ 3F3691E5A9D184DE03C5614C /* WorkspaceFileDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BFFA35FF318BF4E181C0FE /* WorkspaceFileDiscovery.swift */; }; 3F8C871B8FD16CEA3EE5FC5D /* SessionGitSidebarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7E6D3E8923F89B47F5C5A4 /* SessionGitSidebarViewModel.swift */; }; 3FD1D7A44C78DD4C5F14C3A7 /* SessionScrollbackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDF3E0DEEB5F96FA1C459ACF /* SessionScrollbackStore.swift */; }; - 3FF408A7A83C6BFB8FF50AC0 /* VocabularyScreenshotUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3EC024D3B5C2BB547EA212 /* VocabularyScreenshotUITests.swift */; }; 40D55747F5B1B96029268306 /* ProjectOverviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 359C3DC13E13F1E59F8BBC43 /* ProjectOverviewViewModel.swift */; }; 40E4144C522F51072D9D5345 /* GridLayoutEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 620CED7B87823AB08CD08B83 /* GridLayoutEngine.swift */; }; 410AA4AE51FDBB909BE561BF /* CompanionHostStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081F4CCD57333F5F378F9464 /* CompanionHostStorage.swift */; }; @@ -1333,7 +1332,6 @@ F826B96C866E5F1F1BEDB070 /* GridDimensionsPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridDimensionsPicker.swift; sourceTree = ""; }; F88CCB0EF5E481D579CB0EDB /* LedgerDesign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LedgerDesign.swift; sourceTree = ""; }; F9F0079146D1AF9E1A0A6F9F /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; - FA3EC024D3B5C2BB547EA212 /* VocabularyScreenshotUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabularyScreenshotUITests.swift; sourceTree = ""; }; FA83C6EA780D98B29510CDE6 /* folder-server.svg */ = {isa = PBXFileReference; path = "folder-server.svg"; sourceTree = ""; }; FAC321D9AF789EF5234D13AB /* FleetActivityController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FleetActivityController.swift; sourceTree = ""; }; FAEF1F1F164CC43EFF1FE78E /* CIStatusStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIStatusStore.swift; sourceTree = ""; }; @@ -1477,7 +1475,6 @@ E4A98AEF98A101073B3ECE81 /* SettingsUITests.swift */, 8A5491FDE4DC8E390D1F339D /* StartupCheckUITests.swift */, D9A4531D93B3A63280F51879 /* TerminalUITests.swift */, - FA3EC024D3B5C2BB547EA212 /* VocabularyScreenshotUITests.swift */, ); path = FlotillaUITests; sourceTree = ""; @@ -3451,7 +3448,6 @@ 65A0C5DF6D0EA6406B9CE77A /* SettingsUITests.swift in Sources */, D65DAD38EB19053828B3A1F6 /* StartupCheckUITests.swift in Sources */, 94DB32B0489F507550BE625C /* TerminalUITests.swift in Sources */, - 3FF408A7A83C6BFB8FF50AC0 /* VocabularyScreenshotUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Flotilla.xcodeproj/xcshareddata/xcschemes/UI Vocabulary Screenshots.xcscheme b/Flotilla.xcodeproj/xcshareddata/xcschemes/UI Vocabulary Screenshots.xcscheme deleted file mode 100644 index d2149277..00000000 --- a/Flotilla.xcodeproj/xcshareddata/xcschemes/UI Vocabulary Screenshots.xcscheme +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Flotilla/App/UITestWindowPlacer.swift b/Flotilla/App/UITestWindowPlacer.swift index 59e88a2c..c7cd07fa 100644 --- a/Flotilla/App/UITestWindowPlacer.swift +++ b/Flotilla/App/UITestWindowPlacer.swift @@ -2,10 +2,9 @@ import AppKit import SwiftUI -/// Keeps screenshot-producing UI tests on the primary display. +/// Keeps UI-test windows on the primary display, at a known size. /// -/// `XCUIScreenshot` cannot reliably capture windows hosted on a secondary -/// display. This representable is only mounted when `UI_TESTING=1`, so normal +/// This representable is only mounted when `UI_TESTING=1`, so normal /// app launches retain macOS' standard window placement behavior. enum UITestWindowPlacement: Equatable { case fillPrimaryDisplay diff --git a/FlotillaUITests/RestartSessionUITests.swift b/FlotillaUITests/RestartSessionUITests.swift index 1a980942..aac2f31b 100644 --- a/FlotillaUITests/RestartSessionUITests.swift +++ b/FlotillaUITests/RestartSessionUITests.swift @@ -26,19 +26,11 @@ final class RestartSessionUITests: XCTestCase { let exitScreen = element(app, .agentExitScreen) XCTAssertTrue(exitScreen.waitForExistence(timeout: 3)) - let screenshot = XCTAttachment(screenshot: app.windows.firstMatch.screenshot()) - screenshot.name = "AgentExitScreen" - screenshot.lifetime = .keepAlways - add(screenshot) element(app, .agentExitShowOutput).click() XCTAssertTrue(exitScreen.waitForNonExistence(timeout: 3)) let stripRestart = element(app, .agentExitStripRestart) XCTAssertTrue(stripRestart.waitForExistence(timeout: 3)) - let strip = XCTAttachment(screenshot: app.windows.firstMatch.screenshot()) - strip.name = "AgentExitStrip" - strip.lifetime = .keepAlways - add(strip) stripRestart.click() XCTAssertTrue(stripRestart.waitForNonExistence(timeout: 3)) diff --git a/FlotillaUITests/VocabularyScreenshotUITests.swift b/FlotillaUITests/VocabularyScreenshotUITests.swift deleted file mode 100644 index 4330ffa0..00000000 --- a/FlotillaUITests/VocabularyScreenshotUITests.swift +++ /dev/null @@ -1,479 +0,0 @@ -import AppKit -import XCTest - -/// Captures the deterministic UI-testing fixtures used by -/// `docs/ui-vocabulary.md`. -/// -/// This is a documentation pipeline rather than a visual-regression test: it -/// deliberately makes no pixel assertions. Each screenshot is kept as an -/// xcresult attachment and written to the UI-test runner's sandbox. The -/// dedicated `UI Vocabulary Screenshots` scheme publishes the completed set to -/// the documentation after the test action succeeds. -@MainActor -final class VocabularyScreenshotUITests: XCTestCase { - /// The runner's sandbox container, not the developer's real Documents - /// directory. It is the stable handoff point to the scheme post-action. - nonisolated private static let outputDirectory: URL = { - FileManager.default - .urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent("flotilla-vocab-shots", isDirectory: true) - }() - - /// Only captures consumed by the visual appendix are required for a - /// publishable run. Additional captures can be added without changing the - /// publisher contract. - nonisolated private static let requiredCaptureNames: Set = [ - "01-home-dashboard", - "07-sessions-focus", - "10-presentation-grid", - "11-grid-view", - "13-presentation-board", - "15-presentation-focus", - "16-session-git-sidebar", - "17-session-screenshot-panel", - "20-new-session-command-bar", - "21-command-palette", - "22-delete-session-sheet", - "24-settings-terminal", - "25-settings-git", - "26-settings-agents", - "27-settings-companion", - "30-project-overview", - "34-diff-panel-populated", - "36-project-git-commits", - "37-project-files", - "39-project-skills", - "41-knowledge-detail", - ] - - nonisolated(unsafe) private static var capturedNames: Set = [] - nonisolated(unsafe) private static var log: [String] = [] - - override class func setUp() { - super.setUp() - try? FileManager.default.removeItem(at: outputDirectory) - capturedNames = [] - - do { - try FileManager.default.createDirectory( - at: outputDirectory, - withIntermediateDirectories: true - ) - log = ["SCHEMA 1", "OUTPUT \(outputDirectory.path)"] - } catch { - log = ["SCHEMA 1", "UNUSABLE \(error.localizedDescription)"] - } - print("[vocab-shots] \(log.joined(separator: "\n[vocab-shots] "))") - } - - override class func tearDown() { - let missing = requiredCaptureNames.subtracting(capturedNames).sorted() - if missing.isEmpty { - log.append("COMPLETE schema=1 captures=\(capturedNames.count)") - } else { - log.append("INCOMPLETE missing=\(missing.joined(separator: ","))") - } - - let manifest = log.joined(separator: "\n") + "\n" - try? manifest.write( - to: outputDirectory.appendingPathComponent("manifest.txt"), - atomically: true, - encoding: .utf8 - ) - print("[vocab-shots] ==== manifest ====\n\(manifest)") - super.tearDown() - } - - override func setUp() { - super.setUp() - // A missed surface should not prevent the remaining documentation - // captures from being attempted in the same run. - continueAfterFailure = true - } - - private static func note(_ line: String) { - log.append(line) - print("[vocab-shots] \(line)") - } - - // MARK: - Harness - - private func launchedApp() -> XCUIApplication { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.niclassslua.flotilla") - for runningApp in runningApps { - runningApp.forceTerminate() - } - settle(0.5) - - let app = XCUIApplication() - app.launchArguments.append(contentsOf: ["-ApplePersistenceIgnoreState", "YES"]) - app.launchEnvironment["UI_TESTING"] = "1" - app.launchEnvironment["UI_TEST_SETTINGS_SECTION"] = "general" - app.launch() - - let window = app.windows.firstMatch - if fastWait(window, timeout: 8) { - let frame = window.frame - let placement = frame.minX < 1 ? "PRIMARY" : "SECONDARY" - Self.note( - "WINDOW x=\(Int(frame.minX)) y=\(Int(frame.minY)) " - + "\(Int(frame.width))x\(Int(frame.height)) \(placement)" - ) - } - return app - } - - private func element(_ app: XCUIApplication, _ identifier: String) -> XCUIElement { - app.descendants(matching: .any)[identifier].firstMatch - } - - private func element(_ app: XCUIApplication, _ id: AXID) -> XCUIElement { - element(app, id.rawValue) - } - - /// Lets SwiftUI's short selection and presentation transitions settle. - private func settle(_ seconds: TimeInterval = 0.6) { - RunLoop.current.run(until: Date().addingTimeInterval(seconds)) - } - - private func save(_ screenshot: XCUIScreenshot, named name: String, note: String) { - let attachment = XCTAttachment(screenshot: screenshot) - attachment.name = name - attachment.lifetime = .keepAlways - add(attachment) - - let url = Self.outputDirectory.appendingPathComponent("\(name).png") - do { - try screenshot.pngRepresentation.write(to: url, options: .atomic) - Self.capturedNames.insert(name) - Self.note("OK \(name).png — \(note)") - } catch { - Self.note("ATTACHONLY \(name) — \(note) [\(error.localizedDescription)]") - } - } - - private func shootWindow( - _ app: XCUIApplication, - _ name: String, - _ note: String, - containing identifier: String? = nil - ) { - settle() - - if let identifier { - _ = fastWait(element(app, identifier), timeout: 4) - } - let windows = app.windows.allElementsBoundByIndex.filter(\.exists) - let window: XCUIElement? - if let identifier { - window = windows.first { - $0.descendants(matching: .any)[identifier].firstMatch.exists - } - } else { - window = windows.first - } - - guard let window else { - Self.note("NOWINDOW \(name) — \(note)") - return - } - save(window.screenshot(), named: name, note: note) - } - - private func shootElement(_ app: XCUIApplication, id: String, _ name: String, _ note: String) { - guard let target = largestMatch(app, id, timeout: 3) else { - Self.note("MISSING \(name) — no element '\(id)' [\(note)]") - return - } - settle(0.3) - save(target.screenshot(), named: name, note: "\(note) [element \(id)]") - } - - private func shootElement(_ app: XCUIApplication, id: AXID, _ name: String, _ note: String) { - shootElement(app, id: id.rawValue, name, note) - } - - /// SwiftUI can propagate an accessibility identifier to descendants. The - /// largest match is generally the actual tappable container. - private func largestMatch( - _ app: XCUIApplication, - _ identifier: String, - timeout: TimeInterval - ) -> XCUIElement? { - let match = app.descendants(matching: .any)[identifier].firstMatch - guard fastWait(match, timeout: timeout) else { return nil } - return match - } - - private func largestMatch( - _ app: XCUIApplication, - _ id: AXID, - timeout: TimeInterval - ) -> XCUIElement? { - largestMatch(app, id.rawValue, timeout: timeout) - } - - @discardableResult - private func click( - _ app: XCUIApplication, - _ identifier: String, - timeout: TimeInterval = 4 - ) -> Bool { - guard let target = largestMatch(app, identifier, timeout: timeout) else { - Self.note("NAVFAIL could not find '\(identifier)' to click") - return false - } - target.click() - settle(0.5) - return true - } - - @discardableResult - private func click( - _ app: XCUIApplication, - _ id: AXID, - timeout: TimeInterval = 4 - ) -> Bool { - click(app, id.rawValue, timeout: timeout) - } - - /// Falls back to the visible button title because AppKit occasionally - /// exposes only the label, rather than the SwiftUI identifier, to XCTest. - @discardableResult - private func clickButton( - _ app: XCUIApplication, - title: String, - identifier: String, - timeout: TimeInterval = 4 - ) -> Bool { - if let target = largestMatch(app, identifier, timeout: timeout) { - target.click() - settle(0.5) - return true - } - - let button = app.buttons[title].firstMatch - if fastWait(button, timeout: 2) { - button.click() - settle(0.5) - return true - } - - Self.note("NAVFAIL could not find '\(identifier)' or button '\(title)'") - return false - } - - @discardableResult - private func clickButton( - _ app: XCUIApplication, - title: String, - id: AXID, - timeout: TimeInterval = 4 - ) -> Bool { - clickButton(app, title: title, identifier: id.rawValue, timeout: timeout) - } - - private func goToOverview(_ app: XCUIApplication) { - let radio = app.radioButtons["Projects"].firstMatch - if fastWait(radio, timeout: 2) { - radio.click() - settle(0.5) - return - } - click(app, .sidebarOverview) - } - - private func goToSessions(_ app: XCUIApplication) { - let radio = app.radioButtons["Sessions"].firstMatch - if fastWait(radio, timeout: 2) { - radio.click() - settle(0.5) - return - } - // All Sessions left the navigator with the smart lists; the Go menu's - // ⌘2 is the destination's entry point now. - app.typeKey("2", modifierFlags: .command) - settle(0.5) - } - - /// Grid and Board are their own always-present buttons in the global bar - /// now, not segments of a picker that only appeared in some scopes. - private func choosePresentation(_ app: XCUIApplication, _ title: String) { - let button = element(app, AXID.toolbarShow(title)) - if fastWait(button, timeout: 3) { - button.click() - settle(0.6) - return - } - Self.note("NAVFAIL no presentation button '\(title)'") - } - - /// The grid always launches empty — membership is explicit and nothing is - /// a member on a fresh launch. Fill it from the lit group; if the toolbar - /// control is still animating in, fall back to the documented sidebar - /// gesture (a single click toggles grid membership while the grid is up). - private func fillGrid(_ app: XCUIApplication) { - if let addAll = largestMatch(app, .gridAddAllButton, timeout: 8) { - addAll.click() - settle(0.8) - } - if largestMatch(app, .gridView, timeout: 2) == nil { - for title in ["Fix login bug", "Refactor sidebar", "Autonomous workflow loop"] { - _ = click(app, AXID.sessionRow(title), timeout: 2) - } - settle(0.8) - } - } - - /// Opening a surface (Git / Files / Skills / Rules) swaps the masthead — - /// and the surface links it carries — for the return breadcrumb, so the - /// next surface has to be reached back through Overview. Silent when the - /// workspace is already on Overview. - private func returnToProjectOverview(_ app: XCUIApplication) { - guard let bar = largestMatch(app, .projectReturnToOverview, timeout: 2) else { return } - bar.click() - settle(0.6) - } - - private func assertCaptured(_ names: [String]) { - let missing = names.filter { !Self.capturedNames.contains($0) } - XCTAssertTrue(missing.isEmpty, "Missing documentation captures: \(missing.joined(separator: ", "))") - } - - // MARK: - Capture all surfaces in a single pass - - func testCaptureAllSurfaces() { - let app = launchedApp() - XCTAssertTrue( - fastWait(element(app, .homeDashboard), timeout: 12), - "app never reached the home dashboard" - ) - - // 1. Shell and Home dashboard - shootWindow(app, "01-home-dashboard", "Shell and Home dashboard") - shootElement(app, id: .homeDashboard, "02-home-dashboard-element", "Home dashboard") - shootElement(app, id: .homeWidgetGrid, "04-home-widget-grid", "Widget grid") - shootElement(app, id: .homeRecentProjects, "05-home-projects-gallery", "Project cards") - - // 2. Sessions & Focus presentation - goToSessions(app) - if element(app, AXID.sessionRow("Fix login bug")).exists == false { - _ = click(app, AXID.sidebarProjectCollapseToggle("Flotilla"), timeout: 2) - } - shootWindow(app, "07-sessions-focus", "Sessions facet and Focus presentation") - shootElement(app, id: .sidebarList, "08-session-list", "Session list") - shootElement(app, id: AXID.sessionRow("Fix login bug"), "09-session-sidebar-row", "Session row") - - click(app, AXID.sessionRow("Fix login bug")) - settle(1.0) - shootWindow(app, "15-presentation-focus", "Focused terminal presentation") - - // In-session tools: Git Sidebar and Screenshots Panel - if click(app, .sessionBarGitSidebarToggle) { - settle(0.8) - shootWindow(app, "16-session-git-sidebar", "Session Git sidebar") - _ = click(app, .sessionBarGitSidebarToggle) - settle(0.5) - } - - if click(app, .sessionBarScreenshotsToggle) { - settle(0.8) - shootWindow(app, "17-session-screenshot-panel", "Agent screenshots panel") - _ = click(app, .sessionBarScreenshotsToggle) - settle(0.5) - } - - // Fleet presentations: Grid and Board - choosePresentation(app, "Grid") - fillGrid(app) - settle(1.0) - shootWindow(app, "10-presentation-grid", "Grid presentation") - shootElement(app, id: .gridView, "11-grid-view", "Mission control grid") - - choosePresentation(app, "Board") - settle(1.0) - shootWindow(app, "13-presentation-board", "Board presentation") - shootElement(app, id: .kanbanBoard, "14-kanban-board", "Kanban board") - - // 3. Launchers and Modals - goToSessions(app) - if click(app, AXID.sessionRow("Fix login bug")) { - app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: .command) - settle(0.8) - shootWindow(app, "22-delete-session-sheet", "Delete session sheet") - click(app, .deleteSessionCancel) - } - - goToOverview(app) - app.typeKey("n", modifierFlags: .command) - settle(0.8) - shootWindow(app, "20-new-session-command-bar", "New Session command bar") - app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) - settle(0.6) - - app.typeKey("k", modifierFlags: .command) - settle(0.8) - shootWindow(app, "21-command-palette", "Command palette") - app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) - settle(0.6) - - app.typeKey(",", modifierFlags: .command) - XCTAssertTrue(fastWait(element(app, .settingsView), timeout: 6)) - shootWindow(app, "23-settings-general", "Settings General pane", containing: AXID.settingsView.rawValue) - - if clickButton(app, title: "Terminal", identifier: AXID.settingsSidebarTab("terminal")) { - shootWindow(app, "24-settings-terminal", "Settings Terminal pane", containing: AXID.settingsView.rawValue) - } - if clickButton(app, title: "Git & Worktrees", identifier: AXID.settingsSidebarTab("git")) { - shootWindow(app, "25-settings-git", "Settings Git & Worktrees pane", containing: AXID.settingsView.rawValue) - } - if clickButton(app, title: "Coding Agents", identifier: AXID.settingsSidebarTab("agents")) { - shootWindow(app, "26-settings-agents", "Settings Coding Agents pane", containing: AXID.settingsView.rawValue) - } - if clickButton(app, title: "iPhone Companion", identifier: AXID.settingsSidebarTab("companion")) { - shootWindow(app, "27-settings-companion", "Settings iPhone Companion pane", containing: AXID.settingsView.rawValue) - } - app.typeKey("w", modifierFlags: .command) - settle(0.6) - - // 4. Project workspace - goToOverview(app) - clickButton(app, title: "Flotilla", identifier: AXID.projectRow("Flotilla")) - settle(1.0) - shootWindow(app, "30-project-overview", "Project Overview tab") - - if clickButton(app, title: "Git", id: .projectTabGit) { - shootWindow(app, "33-project-git-changes", "Git Changes sub-tab") - if clickButton(app, title: "Simulate Edit", id: .diffPanelSimulateEditButton) { - settle(1.0) - shootWindow(app, "34-diff-panel-populated", "Populated diff panel") - } - if clickButton(app, title: "Commits", identifier: AXID.projectGitSubTab("Commits")) { - settle(1.0) - shootWindow(app, "36-project-git-commits", "Commit graph and history") - } - } - - returnToProjectOverview(app) - if clickButton(app, title: "Files", id: .projectTabFiles) { - settle(1.0) - shootWindow(app, "37-project-files", "File tree and editor pane") - } - returnToProjectOverview(app) - if clickButton(app, title: "Skills", id: .projectTabSkills) { - settle(1.0) - shootWindow(app, "39-project-skills", "Knowledge catalog ledger") - } - returnToProjectOverview(app) - if clickButton(app, title: "Rules", id: .projectTabRules) { - settle(1.0) - shootWindow(app, "40-project-rules", "Rules knowledge catalog") - if click(app, AXID.knowledgeItem("CLAUDE.md")) { - settle(0.8) - shootWindow(app, "41-knowledge-detail", "Knowledge detail pane") - } - } - - assertCaptured(Array(Self.requiredCaptureNames)) - } -} diff --git a/Scripts/update-ui-vocabulary-screenshots.sh b/Scripts/update-ui-vocabulary-screenshots.sh deleted file mode 100644 index 1cc191f6..00000000 --- a/Scripts/update-ui-vocabulary-screenshots.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -SCRIPT_DIRECTORY="$(cd "$(dirname "$0")" && pwd)" -REPOSITORY_ROOT="${SRCROOT:-$(cd "$SCRIPT_DIRECTORY/.." && pwd)}" -RAW_SCREENSHOT_DIRECTORY="${UI_VOCABULARY_RAW_DIR:-$HOME/Library/Containers/com.niclassslua.FlotillaUITests.xctrunner/Data/Documents/flotilla-vocab-shots}" - -mkdir -p "$RAW_SCREENSHOT_DIRECTORY" - -# Ensure companion screenshots are generated -if [ -f "/tmp/generate-companion-screenshots" ]; then - cp -R "$REPOSITORY_ROOT/build/DerivedData/Build/Products/Debug/"*.bundle /tmp/ 2>/dev/null || true - /tmp/generate-companion-screenshots "$RAW_SCREENSHOT_DIRECTORY" || true -fi - -xcrun swift "$REPOSITORY_ROOT/Scripts/update-ui-vocabulary-screenshots.swift" \ - --source "$RAW_SCREENSHOT_DIRECTORY" \ - --destination "$REPOSITORY_ROOT/docs/images/ui-vocabulary" diff --git a/Scripts/update-ui-vocabulary-screenshots.swift b/Scripts/update-ui-vocabulary-screenshots.swift deleted file mode 100644 index bac6a317..00000000 --- a/Scripts/update-ui-vocabulary-screenshots.swift +++ /dev/null @@ -1,475 +0,0 @@ -#!/usr/bin/env swift - -import AppKit -import Foundation - -private struct NormalizedCrop { - let x: Double - let top: Double - let width: Double - let height: Double -} - -private struct Publication { - let sourceName: String - let destinationName: String - let crop: NormalizedCrop? - /// Where this figure belongs in the `docs/ui-vocabulary.md` visual - /// reference. The publisher regenerates that section from this list so - /// every screenshot stays on its own line under its own caption. - let section: String - let caption: String - let alt: String - - init( - _ sourceName: String, - _ destinationName: String, - section: String, - caption: String, - alt: String, - crop: NormalizedCrop? = nil - ) { - self.sourceName = sourceName - self.destinationName = destinationName - self.section = section - self.caption = caption - self.alt = alt - self.crop = crop - } -} - -/// The `docs/ui-vocabulary.md` region the publisher rewrites. Editorial prose -/// lives outside these markers and is never touched. -private let figuresBeginMarker = "" -private let figuresEndMarker = "" - -/// Prose that introduces a section, printed after its `###` heading. -private let sectionIntros: [String: String] = [ - "Project workspace": "The Stream workspace: the masthead over the activity feed on the left, the context column on the right.", - "Mobile companion (iOS)": "The iPhone companion app (`FlotillaCompanion`) pairs with Flotilla over encrypted local network or Tailscale links, giving full remote control over fleet status, live streaming transcripts, and pending agent interaction gates.", -] - -/// Prose that closes a section, printed after its last figure. -private let sectionOutros: [String: String] = [ - "Shell, Home, and Sessions": "The Home view above shows the **project cards** and **customizable widget grid** named in sections 1 and 2.", -] - -private enum PublisherError: LocalizedError { - case usage - case missingManifest(URL) - case incompleteManifest(URL) - case missingSource(URL) - case invalidImage(URL) - case invalidCrop(String) - case couldNotRender(String) - case figureMarkersMissing(URL) - - var errorDescription: String? { - switch self { - case .usage: - return "usage: update-ui-vocabulary-screenshots.swift --source --destination " - case .missingManifest(let url): - return "capture manifest is missing: \(url.path)" - case .incompleteManifest(let url): - return "capture manifest is incomplete; the documentation was not changed: \(url.path)" - case .missingSource(let url): - return "required raw screenshot is missing: \(url.path)" - case .invalidImage(let url): - return "could not decode PNG: \(url.path)" - case .invalidCrop(let name): - return "crop falls outside source image: \(name)" - case .couldNotRender(let name): - return "could not render publication image: \(name)" - case .figureMarkersMissing(let url): - return "\(url.lastPathComponent) is missing the generated-figures markers; " - + "wrap the visual reference in \(figuresBeginMarker) … \(figuresEndMarker)" - } - } -} - -// Order here is the order the figures appear in `docs/ui-vocabulary.md`; the -// `section` values, in first-seen order, become its `###` headings. -private let publications: [Publication] = [ - Publication( - "01-home-dashboard", "shell-home-dashboard.png", - section: "Shell, Home, and Sessions", - caption: "Window shell with Home dashboard", - alt: "Flotilla window shell showing the navigation rail and Home dashboard" - ), - Publication( - "07-sessions-focus", "sessions-facet.png", - section: "Shell, Home, and Sessions", - caption: "Sessions facet with session list", - alt: "Sessions facet showing the navigation rail, session list, and detail column" - ), - Publication( - "10-presentation-grid", "grid-presentation.png", - section: "Fleet presentations and session cards", - caption: "Grid presentation", - alt: "Grid presentation with its toolbar and six terminal tiles" - ), - Publication( - "11-grid-view", "grid-tile.png", - section: "Fleet presentations and session cards", - caption: "Grid tile / session card", - alt: "A grid tile showing the tile header, status, branch, focus control, and terminal surface", - crop: NormalizedCrop(x: 0, top: 0, width: 0.3335, height: 0.5) - ), - Publication( - "13-presentation-board", "board-presentation.png", - section: "Fleet presentations and session cards", - caption: "Board presentation", - alt: "Board presentation with status columns and session cards" - ), - Publication( - "15-presentation-focus", "focus-presentation.png", - section: "Fleet presentations and session cards", - caption: "Focus presentation", - alt: "Focus presentation with one terminal occupying the detail column" - ), - Publication( - "16-session-git-sidebar", "session-git-sidebar.png", - section: "Fleet presentations and session cards", - caption: "In-session Git sidebar", - alt: "In-session Git sidebar docked beside the live terminal surface" - ), - Publication( - "17-session-screenshot-panel", "session-screenshot-panel.png", - section: "Fleet presentations and session cards", - caption: "Agent screenshots panel", - alt: "Agent screenshots feed panel docked beside the live terminal surface" - ), - Publication( - "30-project-overview", "project-overview.png", - section: "Project workspace", - caption: "Project workspace — activity feed and context column", - alt: "Project workspace — activity feed and context column" - ), - Publication( - "30-project-overview", "worktree-section.png", - section: "Project workspace", - caption: "The worktrees block in the context column", - alt: "The worktrees block in the context column", - // The Worktrees block now lives in the Stream workspace's context - // column on the right, not across the foot of the old overview. - crop: NormalizedCrop(x: 0.808889, top: 0.129204, width: 0.188889, height: 0.079646) - ), - Publication( - "36-project-git-commits", "commit-graph.png", - section: "Project workspace", - caption: "Commit graph and history", - alt: "Git Commits tab showing the commit graph, history list, and commit detail" - ), - Publication( - "39-project-skills", "knowledge-ledger.png", - section: "Project workspace", - caption: "Knowledge catalog ledger", - alt: "Skills tab showing the knowledge catalog ledger and inspector" - ), - Publication( - "41-knowledge-detail", "knowledge-detail.png", - section: "Project workspace", - caption: "Knowledge detail", - alt: "Rules tab showing the knowledge catalog ledger and a selected knowledge detail" - ), - Publication( - "34-diff-panel-populated", "diff-panel.png", - section: "Diff and files", - caption: "Diff panel", - alt: "Git Changes tab showing a populated diff panel and changed file row" - ), - Publication( - "37-project-files", "file-browser.png", - section: "Diff and files", - caption: "File browser", - alt: "Files tab showing the file browser's tree and editor pane" - ), - Publication( - "37-project-files", "file-tree.png", - section: "Diff and files", - caption: "File tree region", - alt: "File tree with filter field and selected README file", - // The file browser sits inside the routed surface now, so its tree - // starts to the right of the app navigator, under the return bar. - crop: NormalizedCrop(x: 0.2, top: 0.074336, width: 0.122222, height: 0.168142) - ), - Publication( - "37-project-files", "editor-pane.png", - section: "Diff and files", - caption: "Editor pane region", - alt: "Editor pane showing a rendered Markdown preview and its toolbar", - crop: NormalizedCrop(x: 0.322222, top: 0.074336, width: 0.675556, height: 0.138053) - ), - Publication( - "20-new-session-command-bar", "new-session-window.png", - section: "Launchers and modals", - caption: "New Session window and command bar", - alt: "New Session window showing the command bar over its scrim" - ), - Publication( - "21-command-palette", "command-palette.png", - section: "Launchers and modals", - caption: "Command palette and palette panel", - alt: "Command palette floating over the Home dashboard" - ), - Publication( - "22-delete-session-sheet", "delete-session-sheet.png", - section: "Launchers and modals", - caption: "Delete session sheet", - alt: "Delete session sheet with keep-worktree and delete-worktree actions" - ), - Publication( - "24-settings-terminal", "settings-terminal-editor.png", - section: "Settings", - caption: "Terminal pane", - alt: "Settings window showing the Terminal pane" - ), - Publication( - "25-settings-git", "settings-git-worktrees.png", - section: "Settings", - caption: "Git & Worktrees pane", - alt: "Settings window showing the Git and Worktrees pane" - ), - Publication( - "26-settings-agents", "settings-coding-agents.png", - section: "Settings", - caption: "Coding Agents pane", - alt: "Settings window showing the Coding Agents pane" - ), - Publication( - "27-settings-companion", "settings-companion.png", - section: "Settings", - caption: "iPhone Companion pane", - alt: "Settings window showing the iPhone Companion pane with pairing code and network status" - ), - Publication( - "companion-01-macs", "companion-macs.png", - section: "Mobile companion (iOS)", - caption: "Companion paired Macs view", - alt: "Companion app root view showing list of paired Macs and their status" - ), - Publication( - "companion-02-fleet", "companion-fleet.png", - section: "Mobile companion (iOS)", - caption: "Companion fleet view", - alt: "Companion fleet view showing needs-you attention sessions and project session groups" - ), - Publication( - "companion-03-permission-card", "companion-permission-card.png", - section: "Mobile companion (iOS)", - caption: "Permission request card in session transcript", - alt: "Permission request card prompting the user to allow a command" - ), - Publication( - "companion-04-question-card", "companion-question-card.png", - section: "Mobile companion (iOS)", - caption: "Multiple-choice question card in transcript", - alt: "Interactive multiple-choice question card for answering agent questions" - ), - Publication( - "companion-05-plan-card", "companion-plan-card.png", - section: "Mobile companion (iOS)", - caption: "Plan approval card in transcript", - alt: "Plan approval card showing proposed agent steps and review actions" - ), - Publication( - "companion-06-diff", "companion-diff.png", - section: "Mobile companion (iOS)", - caption: "Companion diff inspection view", - alt: "Companion working changes view with collapsible file diffs and commit header" - ), -] - -/// The one-image-per-line visual reference, rebuilt from `publications`. -private func renderedFigureBlock() -> String { - var lines: [String] = [figuresBeginMarker, ""] - var currentSection: String? - - for (index, publication) in publications.enumerated() { - if publication.section != currentSection { - currentSection = publication.section - lines.append("### \(publication.section)") - lines.append("") - if let intro = sectionIntros[publication.section] { - lines.append(intro) - lines.append("") - } - } - - lines.append("**\(publication.caption)**") - lines.append("") - lines.append("![\(publication.alt)](images/ui-vocabulary/\(publication.destinationName))") - lines.append("") - - let isLastInSection = index == publications.count - 1 - || publications[index + 1].section != publication.section - if isLastInSection, let outro = sectionOutros[publication.section] { - lines.append(outro) - lines.append("") - } - } - - lines.append(figuresEndMarker) - return lines.joined(separator: "\n") -} - -private func regenerateFigures(in markdownURL: URL) throws { - let original = try String(contentsOf: markdownURL, encoding: .utf8) - guard let begin = original.range(of: figuresBeginMarker), - let end = original.range(of: figuresEndMarker), - begin.lowerBound < end.lowerBound else { - throw PublisherError.figureMarkersMissing(markdownURL) - } - let updated = original.replacingCharacters( - in: begin.lowerBound.. (source: URL, destination: URL) { - let arguments = Array(CommandLine.arguments.dropFirst()) - guard arguments.count == 4, - arguments[0] == "--source", - arguments[2] == "--destination" else { - throw PublisherError.usage - } - return ( - URL(fileURLWithPath: arguments[1], isDirectory: true), - URL(fileURLWithPath: arguments[3], isDirectory: true) - ) -} - -private func decodedImage(at url: URL) throws -> CGImage { - guard FileManager.default.fileExists(atPath: url.path) else { - throw PublisherError.missingSource(url) - } - let data = try Data(contentsOf: url) - guard let representation = NSBitmapImageRep(data: data), - let image = representation.cgImage else { - throw PublisherError.invalidImage(url) - } - return image -} - -private func croppedImage( - _ image: CGImage, - crop: NormalizedCrop?, - name: String -) throws -> CGImage { - guard let crop else { return image } - - let sourceWidth = Double(image.width) - let sourceHeight = Double(image.height) - let x = Int((crop.x * sourceWidth).rounded()) - let top = Int((crop.top * sourceHeight).rounded()) - let width = Int((crop.width * sourceWidth).rounded()) - let height = Int((crop.height * sourceHeight).rounded()) - // `CGImage.cropping(to:)` addresses decoded bitmap rows from the image's - // top edge, which is also how the documentation crop specs are measured. - let y = top - - guard x >= 0, y >= 0, width > 0, height > 0, - x + width <= image.width, - y + height <= image.height, - let result = image.cropping(to: CGRect(x: x, y: y, width: width, height: height)) else { - throw PublisherError.invalidCrop(name) - } - return result -} - -private func resizedImage(_ image: CGImage, maximumWidth: Int) throws -> CGImage { - guard image.width > maximumWidth else { return image } - - let scale = Double(maximumWidth) / Double(image.width) - let targetHeight = max(1, Int((Double(image.height) * scale).rounded())) - guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), - let context = CGContext( - data: nil, - width: maximumWidth, - height: targetHeight, - bitsPerComponent: 8, - bytesPerRow: 0, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - throw PublisherError.couldNotRender("\(image.width)x\(image.height)") - } - - context.interpolationQuality = .high - context.draw(image, in: CGRect(x: 0, y: 0, width: maximumWidth, height: targetHeight)) - guard let rendered = context.makeImage() else { - throw PublisherError.couldNotRender("\(image.width)x\(image.height)") - } - return rendered -} - -private func pngData(for image: CGImage, name: String) throws -> Data { - let representation = NSBitmapImageRep(cgImage: image) - guard let data = representation.representation(using: .png, properties: [:]) else { - throw PublisherError.couldNotRender(name) - } - return data -} - -private func publish() throws { - let directories = try parsedDirectories() - let manifestURL = directories.source.appendingPathComponent("manifest.txt") - guard FileManager.default.fileExists(atPath: manifestURL.path) else { - throw PublisherError.missingManifest(manifestURL) - } - let manifest = try String(contentsOf: manifestURL, encoding: .utf8) - guard manifest.split(separator: "\n").contains(where: { $0.hasPrefix("COMPLETE schema=1 ") }) else { - throw PublisherError.incompleteManifest(manifestURL) - } - - let stagingDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("flotilla-ui-vocabulary-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: stagingDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: stagingDirectory) } - - for publication in publications { - let sourceURL = directories.source.appendingPathComponent("\(publication.sourceName).png") - let sourceImage = try decodedImage(at: sourceURL) - let crop = try croppedImage(sourceImage, crop: publication.crop, name: publication.destinationName) - // Full-window captures are 5,120 px on the Retina UI-test display. - // Retaining a 3,200 px master keeps the docs crisp on wide and HiDPI - // displays while avoiding the storage cost of every untouched raw PNG. - let resized = try resizedImage(crop, maximumWidth: 3_200) - let data = try pngData(for: resized, name: publication.destinationName) - try data.write( - to: stagingDirectory.appendingPathComponent(publication.destinationName), - options: .atomic - ) - } - - try FileManager.default.createDirectory( - at: directories.destination, - withIntermediateDirectories: true - ) - for publication in publications { - let stagedURL = stagingDirectory.appendingPathComponent(publication.destinationName) - let destinationURL = directories.destination.appendingPathComponent(publication.destinationName) - let data = try Data(contentsOf: stagedURL) - try data.write(to: destinationURL, options: .atomic) - } - - // docs/images/ui-vocabulary → docs → docs/ui-vocabulary.md - let markdownURL = directories.destination - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("ui-vocabulary.md") - try regenerateFigures(in: markdownURL) - - print("Published \(publications.count) UI vocabulary images to \(directories.destination.path)") - print("Refreshed the visual reference in \(markdownURL.lastPathComponent) — one image per line") -} - -do { - try publish() -} catch { - fputs("UI vocabulary screenshot publisher failed: \(error.localizedDescription)\n", stderr) - exit(EXIT_FAILURE) -} diff --git a/docs/test-value-inventory.md b/docs/test-value-inventory.md index 5152be12..3fe70748 100644 --- a/docs/test-value-inventory.md +++ b/docs/test-value-inventory.md @@ -41,7 +41,6 @@ Counts are **Keep / Strengthen / Consolidate / Remove / Move**. Filenames can co | [SettingsUITests.swift](../FlotillaUITests/SettingsUITests.swift) | 1 | 0 / 1 / 0 / 0 / 0 | Repair: there is no post-edit assertion, so a disconnected settings binding can pass. | | [StartupCheckUITests.swift](../FlotillaUITests/StartupCheckUITests.swift) | 1 | 0 / 1 / 0 / 0 / 0 | Repair: missing banner or dismiss button skips all assertions. Force a known missing tool and require the banner. | | [TerminalUITests.swift](../FlotillaUITests/TerminalUITests.swift) | 1 | 0 / 1 / 0 / 0 / 0 | Repair: terminal existence after typing does not prove the typed probe reached the PTY or renderer. | -| [VocabularyScreenshotUITests.swift](../FlotillaUITests/VocabularyScreenshotUITests.swift) | 3 | 0 / 0 / 0 / 0 / 3 | Keep as documentation generation in the dedicated serial Xcode scheme, outside the default regression run; publisher scripts referenced by the scheme are missing in this checkout. | | [ZZPerfProbeUITests.swift](../FlotillaUITests/ZZPerfProbeUITests.swift) | 1 | 0 / 0 / 0 / 0 / 1 | Print-only, explicitly throwaway snapshot diagnostic; move out of regression gating or delete after diagnosis. | | [AgentBrandTests.swift](../FlotillaUnitTests/AgentBrandTests.swift) | 6 | 1 / 0 / 0 / 5 / 0 | Keep framework-to-agent identity mapping; remove color/opacity snapshots and convenience-forwarding checks. | | [AgentManagedWorktreeAndTitleTests.swift](../FlotillaUnitTests/AgentManagedWorktreeAndTitleTests.swift) | 4 | 4 / 0 / 0 / 0 / 0 | Keep launch instruction wiring and reported worktree persistence, especially late reports replacing a fallback. Inject support directories and register cleanup. | @@ -123,9 +122,6 @@ Rows below override Keep for the named method only. Consolidation pairs retain a | [SettingsUITests.testWorktreeBaseDirectorySettingUpdate](../FlotillaUITests/SettingsUITests.swift#L13) | Strengthen | No result assertion after typing. Reopen Settings or create a worktree and verify the new configured value/path. | | [StartupCheckUITests.testMissingDependencyShowsNonBlockingWarningBanner](../FlotillaUITests/StartupCheckUITests.swift#L13) | Strengthen | All checks are conditional. Set UI_TESTING_SIMULATE_MISSING_TOOLS and require warning/dismissal plus continued app usability. | | [TerminalUITests.testSendingInputRendersInTerminal](../FlotillaUITests/TerminalUITests.swift#L13) | Strengthen | Only terminal.exists after typing; assert the unique probe is rendered/echoed via the real UI input path. | -| [VocabularyScreenshotUITests.testCaptureShellAndFleetSurfaces](../FlotillaUITests/VocabularyScreenshotUITests.swift#L275) | Move | Documentation artifact generation: retain the dedicated serial Xcode scheme, exclude from ordinary regression selection, restore/verify missing publisher scripts. | -| [VocabularyScreenshotUITests.testCaptureLaunchersAndModals](../FlotillaUITests/VocabularyScreenshotUITests.swift#L316) | Move | Documentation artifact generation: retain the dedicated serial Xcode scheme, exclude from ordinary regression selection, restore/verify missing publisher scripts. | -| [VocabularyScreenshotUITests.testCaptureProjectWorkspace](../FlotillaUITests/VocabularyScreenshotUITests.swift#L363) | Move | Documentation artifact generation: retain the dedicated serial Xcode scheme, exclude from ordinary regression selection, restore/verify missing publisher scripts. | | [ZZPerfProbeUITests.testSnapshotCost](../FlotillaUITests/ZZPerfProbeUITests.swift#L6) | Move | No assertions/baseline; move the temporary diagnostic outside regression execution or remove it. | | [AgentBrandTests.testAgentKindAccentColors](../FlotillaUnitTests/AgentBrandTests.swift#L37) | Remove | Exact palette/opacity or forwarding snapshots; RGB distance does not establish perceptual accessibility. Inspect the design. | | [AgentBrandTests.testSkillFrameworkAccentColorsMatchAgentColors](../FlotillaUnitTests/AgentBrandTests.swift#L53) | Remove | Exact palette/opacity or forwarding snapshots; RGB distance does not establish perceptual accessibility. Inspect the design. | diff --git a/docs/testing.md b/docs/testing.md index 858d2244..109cc542 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -89,8 +89,6 @@ UI tests should verify wiring that lower layers cannot establish. Retain a small Do not combine unrelated journeys merely to reduce app launches: an early failure hides later coverage. Share reliable setup, and combine steps only when they form one meaningful user journey. -`VocabularyScreenshotUITests` is a documentation generator. Keep it in its dedicated Xcode scheme and inspect the produced artifacts; exclude it from the ordinary regression lane. The publisher and required capture manifest must be available and maintained together. - Print-only diagnostics such as `ZZPerfProbeUITests` do not belong in a pass/fail regression suite. Performance checks need a defined workload, a completion condition, a justified threshold or baseline, and output that identifies the regression. Prefer a deterministic work bound where possible; measure wall-clock regressions in a controlled performance lane when machine load affects the verdict. ## Choosing verification diff --git a/docs/ui-vocabulary.md b/docs/ui-vocabulary.md index c5986879..6abfde12 100644 --- a/docs/ui-vocabulary.md +++ b/docs/ui-vocabulary.md @@ -317,9 +317,7 @@ in the same change. ## Visual reference -These screenshots are navigation aids for the vocabulary above, not pixel-accuracy specifications. They use Flotilla's deterministic UI-testing fixtures. - - +These screenshots are navigation aids for the vocabulary above, not pixel-accuracy specifications. They were captured from Flotilla's deterministic UI-testing fixtures and are no longer regenerated automatically, so recapture one by hand when its surface changes noticeably. ### Shell, Home, and Sessions @@ -460,15 +458,3 @@ The iPhone companion app (`FlotillaCompanion`) pairs with Flotilla over encrypte **Companion diff inspection view** ![Companion working changes view with collapsible file diffs and commit header](images/ui-vocabulary/companion-diff.png) - - - -### Refreshing the visual reference - -In Xcode, select the **UI Vocabulary Screenshots** scheme and choose **Product → Test**. The scheme runs only `VocabularyScreenshotUITests`; after every complete, successful capture it validates the manifest, crops the documentation variants, retains a high-resolution 2× master, republishes the 30 PNGs in `docs/images/ui-vocabulary/`, and rewrites the figure blocks between the `BEGIN/END GENERATED FIGURES` markers above — one image per line, captions and section headings included. The prose outside those markers is hand-maintained; edit captions, alt text, and section grouping in the `publications` list in `Scripts/update-ui-vocabulary-screenshots.swift`. - -Run this workflow from Xcode so the UI-test runner inherits Xcode's Accessibility permission. A failed or partial run leaves the checked-in documentation images and this section unchanged. To retry only the publishing step after a completed run, use: - -```bash -/bin/bash Scripts/update-ui-vocabulary-screenshots.sh -``` diff --git a/project.yml b/project.yml index 748c66f3..1921f247 100644 --- a/project.yml +++ b/project.yml @@ -348,19 +348,3 @@ schemes: archive: config: Ephemeral - UI Vocabulary Screenshots: - build: - targets: - Flotilla: all - FlotillaUITests: [test] - test: - targets: - - name: FlotillaUITests - parallelizable: false - selectedTests: - - VocabularyScreenshotUITests - postActions: - - name: Publish UI vocabulary screenshots - settingsTarget: Flotilla - script: | - /bin/bash "$SRCROOT/Scripts/update-ui-vocabulary-screenshots.sh" From f04d91300780764772ad636fd6ad29806e4fa0bd Mon Sep 17 00:00:00 2001 From: Niclassslua <78554432+Niclassslua@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:49:35 +0200 Subject: [PATCH 11/11] test: scroll to the reviewed file first; bound flushes by coalescing, not scheduling --- FlotillaUITests/SessionReviewUITests.swift | 7 +++++++ FlotillaUnitTests/TerminalPipelineTests.swift | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/FlotillaUITests/SessionReviewUITests.swift b/FlotillaUITests/SessionReviewUITests.swift index 13990c5e..053fb550 100644 --- a/FlotillaUITests/SessionReviewUITests.swift +++ b/FlotillaUITests/SessionReviewUITests.swift @@ -76,6 +76,13 @@ final class SessionReviewUITests: XCTestCase { let app = launchedApp() _ = openReview(app) + // The diff pane is lazy: on a short display the reviewed file's + // section isn't built until it's scrolled to. Select it in the file + // list, as a reviewer would, which scrolls the pane there. + let fileRow = element(app, AXID.reviewFileRow(Self.reviewedFile)) + XCTAssertTrue(fastWait(fileRow, timeout: 10), "the changed file should be listed") + fileRow.click() + let commentButton = element(app, AXID.reviewCommentOnFile(Self.reviewedFile)) XCTAssertTrue(fastWait(commentButton, timeout: 10), "the file section should offer a file comment") commentButton.click() diff --git a/FlotillaUnitTests/TerminalPipelineTests.swift b/FlotillaUnitTests/TerminalPipelineTests.swift index c718b71c..3ae80d77 100644 --- a/FlotillaUnitTests/TerminalPipelineTests.swift +++ b/FlotillaUnitTests/TerminalPipelineTests.swift @@ -109,9 +109,13 @@ final class TerminalControllerCoalescingTests: XCTestCase { try await Task.sleep(for: .milliseconds(200)) XCTAssertGreaterThan(controller.outputFlushCount, 0) + // 20 still demands a tenfold collapse. A tighter bound measures + // scheduling, not coalescing: under a loaded CI runner the burst + // straddled 11 flushes. The exact one-flush-per-drain contract is + // pinned deterministically by `CoalescingOutputBufferTests`. XCTAssertLessThanOrEqual( controller.outputFlushCount, - 5, + 20, "200 PTY reads must collapse into a handful of flushes, not one per read" ) }