Skip to content

UI testing - accessibility IDs, seams and suite - #122

Merged
graeme merged 9 commits into
mainfrom
ui-tests-phase-2
Aug 9, 2026
Merged

UI testing - accessibility IDs, seams and suite#122
graeme merged 9 commits into
mainfrom
ui-tests-phase-2

Conversation

@graeme

@graeme graeme commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

PR: UI testing - identity, process-boundary seams, and a deterministic suite

Summary

Takes the app from no UI tests to a 26-test deterministic suite wired into CI. Only the two process
boundaries are mocked (network via a stubbed URLProtocol, shell via a fake brew), so all of our
own code runs under test, error paths included.

Changes

Identity

  • BrewAccessibilityID, a dependency-free target linked by both the app and BrewUITests, spells
    each identifier once. AXIDTests pins the wire format, so drift breaks a unit test.

Seams, both inert in production

  • URLSessionBrewAPIClient.stubbed(protocolClasses:) sets protocol classes on one ephemeral
    session, never globally, which would also capture .shared.
  • BrewCommandExecutionContext.uiTesting(brewURL:) runs the real BrewCommandService against a
    fake executable, without the login shell wrapper, for every brew invocation in the process.
  • BrewApp.init() branches once, on -uiTesting, and redirects on-disk caches per run so runs
    cannot affect each other or a real install.

Suite (BrewUITests/)

  • Elements/ is the only layer touching XCUIElement, and every operation self-waits. Screens/
    returns the next screen from each action, so illegal navigation fails to compile.
  • FakeBrew is a fixture-table bash dispatcher across 10 scenarios, delivered in the launch
    environment and written to disk by the app.
  • Coverage spans navigation, Installed, install, uninstall, config, doctor and console, plus the
    error cases this approach exists for: a 500 through the real API client, output the real decoder
    refuses, a non-zero exit, and a locator that resolves nothing.

Tooling

  • scripts/test-ui, a parallel ui-test CI job, and BrewUITests/TROUBLESHOOTING.md.

Testing

  • scripts/test-ui - 26 UI tests pass
  • scripts/test - 636 + 19 unit tests pass
  • SwiftFormat, SwiftLint --strict, BrewUILint clean

PR checklist

  • Have you followed this repository's contribution and workflow guidance?
  • Have you explained what changed and why this should land now?
  • Have you run relevant local checks for the changed scope?
  • Are changes scoped and free of unrelated modifications?

  • AI was used to generate or assist with generating this PR.
  • If yes, describe exactly how AI was used and what manual verification was performed.

Claude Code wrote the module, seams and suite; verification was a full scripts/test-ui run on a
real machine, so every assertion is backed by a passing run, not inspection.

Follow-ups

  • Real network, real brew, state isolation, Brew.raw and the live plan land next.

Copilot AI lite review requested due to automatic review settings August 9, 2026 08:31
Comment thread .github/workflows/pr_build_test.yml Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a deterministic macOS UI test suite for BrewUI by adding a shared accessibility-ID module, formalizing UI-testing launch/fixture contracts, and adding production-inert seams for the two process boundaries (network + brew subprocess). It also wires UI tests into CI and adds local tooling/docs to run and debug the suite.

Changes:

  • Add shared, dependency-free modules for UI-test identity (BrewAccessibilityID / AXID) and UI-test launch contract + fixture payload (BrewUITestContract).
  • Add UI-testing seams in the app composition root to stub URLSession traffic via URLProtocol and to route all brew invocations through a single execution context (including UI-testing fake-brew wiring).
  • Add a 26-test Page Object Model UI test suite (BrewUITests/), plus scripts/test-ui and a dedicated CI job.

Reviewed changes

Copilot reviewed 67 out of 68 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
Tests/BrewAccessibilityIDTests/AXIDTests.swift Pins AXID raw identifier wire format with unit tests to catch drift early.
Sources/BrewUITestContract/BrewUITestingFixturePayload.swift Defines UI-test launch environment keys and the encoded fixture payload contract.
Sources/BrewUIComponents/Views/View+AXID.swift Adds a View modifier to attach AXID identifiers to SwiftUI views.
Sources/BrewUIComponents/Views/AsyncContentView.swift Tags shared error chrome with AXID identifiers for UI testing.
Sources/BrewRepositories/BrewInstalledPackagesRepository.swift Routes brew info through an injected execution context (supports UI testing).
Sources/BrewRepositories/BrewConfigRepository.swift Routes brew config through an injected execution context (supports UI testing).
Sources/BrewNetworking/BrewAPIClient.swift Adds a per-session URLProtocol seam for in-process network stubbing.
Sources/BrewFeatureInstalled/Views/UpgradesPackagesView.swift Adds AXID identifiers for Upgrades screen, list, and rows.
Sources/BrewFeatureInstalled/Views/InstalledPackagesView.swift Adds AXID identifiers for Installed screen, list, and rows.
Sources/BrewFeatureInstalled/Views/InstalledPackageDetailView.swift Adds AXID identifiers for detail pane + uninstall/upgrade affordances.
Sources/BrewFeatureDoctor/Views/DoctorView.swift Adds AXID identifier for Doctor screen root.
Sources/BrewFeatureDiscover/Views/DiscoverPackagesView.swift Adds AXID identifiers for Discover screen + list (notes toolbar-search limitation).
Sources/BrewFeatureDiscover/Views/DiscoverPackageDetailView.swift Adds AXID identifiers for detail pane + install button.
Sources/BrewFeatureConsole/Views/ConsoleToolbar.swift Adds AXID identifier and explicit accessibility label for console toggle.
Sources/BrewFeatureConsole/Views/ConsoleStatusBar.swift Combines status text for VO/UI testing and adds AXID identifiers/labels.
Sources/BrewFeatureConsole/Views/ConsolePanel.swift Tags console container with AXID and makes it an accessibility container.
Sources/BrewFeatureConsole/Views/ConsoleBody.swift Tags console output list with an AXID identifier.
Sources/BrewFeatureConfig/Views/ConfigView.swift Adds AXID identifiers for Configuration screen + brew-not-found empty state.
Sources/BrewCLI/BrewCommandExecutionContext+UITesting.swift Adds .uiTesting(brewURL:) execution context for fake-brew + safe “no brew” mode.
Sources/BrewAccessibilityID/AXID.swift Introduces the shared AXID enum that defines all stable UI test identifiers.
scripts/test-ui Adds a local script to run the UI test plan like CI.
Package.swift Adds new SwiftPM products/targets and wires dependencies for BrewAccessibilityID.
Homebrew/Views/SidebarItem.swift Adds explicit mapping from app sidebar destinations to test destinations (AXID).
Homebrew/Views/MainSidebarView.swift Adds AXID identifiers for sidebar container and sidebar row items.
Homebrew/UITesting/BrewUITestingStubURLProtocol.swift Implements a fixture-driven URLProtocol responder inside the app process.
Homebrew/UITesting/BrewUITestingLaunchConfiguration.swift Parses UI-testing launch arguments/environment into a single config object.
Homebrew/UITesting/BrewUITestingFixtureInstaller.swift Installs fixture payload into app-owned temp dir and publishes paths via env.
Homebrew/BrewApp.swift Branches once for UI testing: installs fixtures, isolates caches, swaps seams.
Homebrew.xcodeproj/project.pbxproj Links new SwiftPM products into app/UI test targets; adjusts build settings.
CONVENTIONS.md Updates documented convention: use AXID + .axid(_:) and never raw ID strings.
BrewUITests/TROUBLESHOOTING.md Documents platform/environment gotchas and debugging guidance for macOS UI tests.
BrewUITests/Tests/UninstallUITests.swift UI tests for uninstall flow and inventory reconciliation.
BrewUITests/Tests/NavigationUITests.swift UI tests ensuring each sidebar destination loads and navigation is stable.
BrewUITests/Tests/LaunchSmokeUITests.swift Smoke tests that -uiTesting launch and empty scenario behave correctly.
BrewUITests/Tests/InstallUITests.swift UI tests for Discover search + install + Installed reconciliation path.
BrewUITests/Tests/InstalledUITests.swift UI tests for installed inventory rendering, search, upgrades slice, large output.
BrewUITests/Tests/ErrorStateUITests.swift UI tests for error surfaces across HTTP failures, decode failures, missing brew.
BrewUITests/Tests/DoctorUITests.swift UI tests for Doctor healthy + warnings (non-zero exit treated as data).
BrewUITests/Tests/ConsoleUITests.swift UI tests for streamed console output and success/failure status reporting.
BrewUITests/Tests/ConfigUITests.swift UI tests for parsed brew config entries.
BrewUITests/Screens/UpgradesScreen.swift Page object for Upgrades screen.
BrewUITests/Screens/Sidebar.swift Page object for sidebar navigation returning loaded screens.
BrewUITests/Screens/Screen.swift Base screen protocol + shared chrome assertions/actions (sidebar/console/error).
BrewUITests/Screens/PackageDetailScreen.swift Page object for detail pane actions (install/uninstall/upgrade).
BrewUITests/Screens/InstalledScreen.swift Page object for Installed screen.
BrewUITests/Screens/DoctorScreen.swift Page object for Doctor screen assertions.
BrewUITests/Screens/DiscoverScreen.swift Page object for Discover screen + search behavior.
BrewUITests/Screens/ConsoleScreen.swift Page object for console expand/collapse and output/status assertions.
BrewUITests/Screens/ConfigScreen.swift Page object for Configuration screen assertions.
BrewUITests/Harness/FakeBrew.swift Builds the fake brew dispatcher + fixture payload for scenarios.
BrewUITests/Harness/BrewUITestScenario.swift Enumerates the suite’s deterministic scenarios.
BrewUITests/Harness/BrewUITestDiagnostics.swift Improves failure logs with foreground/window/tree diagnostics.
BrewUITests/Harness/BrewUITestCase.swift Base XCTest case with launch helpers and process cleanup.
BrewUITests/Harness/BrewApp.swift Launches app with payload/env and applies macOS foreground/window workaround.
BrewUITests/Fixtures/ScenarioFixtures.swift Provides per-scenario fixture sets for both HTTP and fake-brew boundaries.
BrewUITests/Fixtures/FixturePackage.swift Single-source package metadata rendered into each wire format fixture.
BrewUITests/Elements/BrewUIStaticText.swift Element wrapper for stable text assertions (label/value handling).
BrewUITests/Elements/BrewUISearchField.swift Special-cased toolbar search field wrapper (no AXID possible currently).
BrewUITests/Elements/BrewUIList.swift List wrapper providing row lookup + row count assertions by AXID prefix.
BrewUITests/Elements/BrewUIElement.swift Core element wrapper enforcing self-waiting and better diagnostics.
BrewUITests/Elements/BrewUICell.swift Row wrapper for clicking token-addressed list rows.
BrewUITests/Elements/BrewUIButton.swift Button wrapper using macOS click() and hittability waits.
BrewUITests/BrewUITestsLaunchTests.swift Removes template UI test class in favor of the new suite.
BrewUITests/BrewUITests.swift Removes template UI test class in favor of the new suite.
Brew-UI.xctestplan Configures UI test plan options (screenshots, random ordering, timeouts).
.gitignore Ignores UI test result bundles and coverage .profraw artifacts.
.github/workflows/pr_build_test.yml Adds a dedicated CI job to run UI tests via scripts/test-ui.
.ai/memory.md Records durable decisions/patterns introduced by the UI testing plan.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Sources/BrewUITestContract/BrewUITestingFixturePayload.swift
Comment thread Sources/BrewUIComponents/Views/View+AXID.swift
Comment thread .github/workflows/pr_build_test.yml
graeme and others added 5 commits August 9, 2026 18:59
UI testing needs two things spelled exactly once, in a place both the app
and the test bundle can see: the identifier of every testable element, and
the launch contract between the two processes. Both live in dependency-free
SwiftPM targets so linking them into the test bundle cannot drag app code
along with it.

BrewAccessibilityID holds AXID, whose rawValue is the only place an
identifier string is written. Views attach it with .axid(_:) rather than
accessibilityIdentifier with a literal, and AXIDTests pins the wire format
so drift breaks a unit test instead of surfacing as a missing element in a
UI test.

BrewUITestContract holds the launch environment keys and the fixture
payload the two processes exchange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Attaches AXID across the app: the sidebar and its rows, the five primary
screens, the package detail views, the console, and the shared failure
chrome in AsyncContentView.

Two choices worth knowing about. The error state is deliberately
screen-agnostic, because every loadable surface renders the same view, so
a test scopes the query to a screen root to say which surface failed. And
Upgrades rows carry their own identity rather than reusing the Installed
ones: the same package is legitimately in both lists at once, and sharing
ids would let an assertion about one be satisfied by the other.

Identity stays orthogonal to labels throughout. accessibilityLabel remains
for VoiceOver; these identifiers exist only so tests never match on
displayed text or index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two process boundaries, both public, both inert in production.

URLSessionBrewAPIClient.stubbed(protocolClasses:) sets protocol classes on
one ephemeral session's configuration rather than calling
URLProtocol.registerClass, which would also capture .shared and therefore
every other client in the process.

BrewCommandExecutionContext.uiTesting(brewURL:) uses the real
BrewCommandService against a fake executable, so Process spawning, pipes,
output streaming and exit handling all stay under test. Deliberately not
LoginShellBrewCommandRunner: wrapping a fake brew in the developer's login
shell would re-introduce the dotfile dependence that runner exists to
provide in production. A nil URL resolves nothing, which is how a test
drives the brew-not-found surfaces.

BrewInstalledPackagesRepository and BrewConfigRepository now take an
execution context instead of each constructing its own runner and locator,
so one context covers every brew invocation in the process rather than
just the command center's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BrewApp.init() makes one check, on the launch argument, and every seam is a
single guard away from the untouched production wiring. Being in UI-test
mode is never threaded further into the app.

Under that flag the app writes the run's fixture tree into its own
temporary directory from the payload in its launch environment. The
alternative -- the test runner writes files and the app reads them --
needs one directory two processes are both allowed to use, and there is no
reliable one: a path the runner can write is not necessarily one the app
can read and execute from, and the failure is an opaque EPERM on whichever
side loses. Letting one process create, execute and own the files removes
the question. The installer setenvs the resulting paths because the stub
URLProtocol reads them from a URLSession loading thread and the fake brew
inherits them as a subprocess.

BrewUITestingStubURLProtocol answers from those fixtures keyed by request
path, so the real client still builds requests, negotiates ETag/304,
decodes and caches; an error case is a fixture rather than a code path. The
on-disk caches are redirected to a per-run container so one run cannot
decide the next run's behaviour or overwrite a real install's cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Elements are the only layer that touches XCUIElement. Every operation
self-waits, because a bare exists read races the app's next render and is
the largest single source of XCUITest flake, and failures are anchored to
the caller so a red test points at the assertion rather than into the
wrapper. Screens own where things are; tests own what should be true, and
actions return the next screen so an illegal sequence fails to compile.

FakeBrew is a lookup table rather than a case over subcommands: stdout,
stderr and exit-code files are all optional, so adding a command to a
scenario means adding a file. A .next-info file is its only state, and it
is what makes an uninstall actually remove a row -- the repository
reconciles off the command centre's completion stream and has to see a
changed world.

The suite covers navigation, Installed, install, uninstall, config, doctor
and console, plus the error cases this whole approach exists for: a 500
through the real API client, output the real decoder refuses, a non-zero
exit from the real runner, and a locator that resolves nothing. Failures
report whether the app is running, foregrounded and has a window, since
"element not found" cannot otherwise distinguish a launch problem from a
wrong identifier.

Replaces the Xcode template, which launched the app with no -uiTesting
argument and so ran against the real network and the machine's real brew.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 67 out of 68 changed files in this pull request and generated no new comments.

Suppressed comments (11)

.github/workflows/pr_build_test.yml:39

  • The pull-request filter has the same gap: changes confined to Homebrew/** or Brew-UI.xctestplan will not trigger this workflow, so the new UI-test job may not run when app UI or test-plan behavior changes.
      - "scripts/test-ui"

.github/workflows/pr_build_test.yml:19

  • The new UI-test job can still be skipped for app-only or test-plan-only pushes: this filter watches Brew/**, but the app sources are under Homebrew/**, and Brew-UI.xctestplan is not listed. Add both paths so changes to the code under test or its plan run the suite.

This issue also appears on line 39 of the same file.

      - "scripts/test-ui"

Sources/BrewUIComponents/Views/AsyncContentView.swift:72

  • This identifier is attached to a button that the parent later collapses with .accessibilityElement(children: .combine). The Retry button therefore is not preserved as a separately queryable/clickable error.retry element, so Screen.retry() cannot address it and VoiceOver loses the distinct Retry control. Keep the button outside the combined element or use a containing structure that preserves interactive children.
    BrewUITests/Elements/BrewUIList.swift:51
  • This assertion reads the query count only once instead of self-waiting. While Installed is loading, AsyncContentView renders four identified placeholder rows, so the empty-inventory smoke test can fail on a slower machine before the real zero-row result arrives. Poll the row query until its count matches or the timeout expires.
    func assertCount(
        _ expected: Int,
        file: StaticString = #filePath,
        line: UInt = #line,
    ) -> Self {
        XCTAssertEqual(count, expected, "Unexpected row count in \(id.rawValue)", file: file, line: line)

BrewUITests/Tests/LaunchSmokeUITests.swift:27

  • Neither assertion proves that the async inventory load reached .loaded: the no-error check succeeds immediately during .loading, and a later .failed state also has zero package rows. Depending on timing, this test can pass for the failure it explicitly intends to reject. Add an explicit loaded-success accessibility signal and wait for it before checking zero rows/no error.
    BrewUITests/Tests/ErrorStateUITests.swift:75
  • This negative assertion runs as soon as the screen root appears, which is before the asynchronous inventory request settles, so it can pass and end the test before a later error renders. Gate it on a known package first so the test proves the healthy load completed.
    BrewUITests/Tests/ConsoleUITests.swift:27
  • These final-state assertions do not prove streaming. The fake brew uses one cat and exits immediately, and assertOutputContains may wait until after exit; an implementation that buffers everything until termination would still render both rows and pass. Add a delayed/gated fixture and assert the first line while the command is still running before allowing it to finish.
    BrewUITests/Fixtures/ScenarioFixtures.swift:259
  • These ETag files do not currently exercise the 304 branch. Each launch creates a new cache directory and clears the UI-test defaults, and after the initial 200 response the catalogue remains fresh, so no test sends a second request with If-None-Match. Add a pre-seeded-cache scenario or force a refresh after the first fetch before claiming 304 coverage.
            // A stable ETag on the catalogue so a second launch in the same run exercises the real
            // client's If-None-Match / 304 path rather than always taking the 200 branch.
            "api_formula.json.etag": text("\"formula-fixture\""),
            "api_cask.json.etag": text("\"cask-fixture\""),

Homebrew/UITesting/BrewUITestingStubURLProtocol.swift:11

  • This documents the discarded on-disk handoff design. BrewUITestingFixtureInstaller now decodes the launch-environment payload and writes the fixture files from the app process during launch, not from the test process before launch.
    BrewUITests/Harness/BrewUITestScenario.swift:12
  • The test process no longer materializes fixtures on disk. It sends an encoded payload, and the app installs that tree into its own temporary directory during launch; update this contract description so future harness changes follow the implemented boundary.
/// A scenario names one fixture set, which the test process materialises to disk before launch and
/// which both process boundaries then read: the fake `brew` serves `<scenario>/brew`, and the app's
/// stubbed `URLSession` serves `<scenario>/http`. Because both seams are data, an error case is a

.ai/memory.md:535

  • This durable memory entry records the superseded design and even names a nonexistent FakeBrew.install(scenario:). The current implementation has FakeBrew.payload(for:) build bytes that BrewUITestingFixtureInstaller writes from the app process; correct this entry because .ai/memory.md is the repository's long-term source of truth.
- **One fixture tree feeds both seams.** `FakeBrew.install(scenario:)` writes a per-run temp directory containing `<scenario>/brew` (fake-brew fixtures) and `<scenario>/http` (response bodies), and hands the app the paths via launch environment. The HTTP responder lives **in the app** (`BrewUITestingStubURLProtocol`), not in `BrewUITests`: a `URLProtocol` registered in the test target runs in the *test* process and would never see the app's traffic. PR 2's plan offered both wirings; this is the one chosen.

@graeme
graeme force-pushed the ui-tests-phase-2 branch 2 times, most recently from 16b7a29 to a76d9a0 Compare August 9, 2026 09:07
graeme and others added 4 commits August 9, 2026 22:10
scripts/test-ui mirrors what CI runs, so green locally means green on CI.
It deliberately does not override -derivedDataPath: a project-relative
DerivedData location reproducibly makes the test runner hang for around
five minutes before failing to establish a connection, where the identical
run against the default location passes in seconds.

The ui-test CI job runs alongside the unit job with no needs: dependency,
so UI coverage costs wall-clock time on a separate runner rather than
sitting on the critical path.

It replaces ui_smoke.yml, which triggered on the same pull_request event
and ran the same Brew-UI scheme, so keeping both would run every test
twice. Its inline signing block is gone with it: the default path was the
ad-hoc signing scripts/test-ui already does, and keeping the logic in the
script is what keeps a local run honest about CI.

TROUBLESHOOTING.md covers the things that make a local run fail for
reasons unrelated to the code, since the suite drives real windows and
real input.

Also ignores the artefacts a UI run leaves behind: coverage drops .profraw
wherever an instrumented binary runs, and the script writes a result bundle
at the repository root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captures what the diff cannot show: why the identifier module is
dependency-free and linked by both targets, why each seam is shaped the way
it is, why the fixture tree travels in the launch environment rather than
on disk, and why activating the app after launch is load-bearing rather
than cosmetic.

CONVENTIONS.md pointed at Utilities/AccessibilityIdentifiers.swift, which
was never created; BrewAccessibilityID is what actually fills that role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
actions/checkout leaves a token in .git/config by default, and this job
uploads artifacts on failure, so the credential can ride along. Nothing
here pushes, so dropping it costs nothing. Flagged by zizmor; the rest of
the repository's workflows already set this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lets the workflow be run against any branch from the Actions tab, and takes
an optional -only-testing filter so a single UI test can be re-run without
pushing a commit. The filter reaches the script through the environment
rather than being interpolated into the run block, so the input cannot
inject shell.

The concurrency group gains a ref fallback: a dispatched run has no pull
request number, so without it every manual run would share one group and
cancel whichever was already going.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@graeme
graeme force-pushed the ui-tests-phase-2 branch from a76d9a0 to 592ff07 Compare August 9, 2026 12:18
@graeme
graeme merged commit 136624c into main Aug 9, 2026
10 checks passed
@graeme
graeme deleted the ui-tests-phase-2 branch August 9, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet