Skip to content

UI tests phase 3 - #128

Draft
graeme wants to merge 11 commits into
mainfrom
ui-tests-phase-3
Draft

UI tests phase 3#128
graeme wants to merge 11 commits into
mainfrom
ui-tests-phase-3

Conversation

@graeme

@graeme graeme commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

PR: Live end-to-end canaries (Tier 3)

Summary

Adds a small suite that runs the app with production wiring against real Homebrew and the real network, plus the test plan, script and schedule to run it. Tier 2 answers from fixtures, so it cannot notice when those fixtures have started lying; this suite can.

Changes

Live suite (BrewUITests/E2E/)

  • BrewE2EApp launches with no -uiTesting argument, so BrewApp.init() falls through to .live() on both seams: real login shell, real brew, real URLSession.shared, real bottles. The only injection is Homebrew's determinism environment.
  • Brew is a Process helper used to arrange and clean up state, never as the code under test. Tests act through PR 2's page objects unchanged.
  • Six tests over install, uninstall, config, search, navigation and console. Assertions read shape, not values, so a version bump cannot turn them red.
  • The mutating tests own the formula hello, force-uninstall it before and after every test, and are order independent. Nothing else may depend on it.

Wiring

  • New Brew-E2E.xctestplan and scripts/test-e2e: serial, minute-scale allowances, one retry. Skipped by name in Brew-UI.xctestplan.
  • e2e_nightly.yml runs it nightly on an ephemeral runner. The release workflow is untouched.
  • BrewApp.activate and app tracking in BrewUITestCase are now reachable by a second launcher.
  • Docs: E2E/README.md, a TROUBLESHOOTING entry, an AGENTS.md warning, a memory entry.

Why this split

Upgrade and doctor are excluded by design: upgrade needs a deterministically outdated package and doctor's output is entirely machine dependent, so neither has a stable happy path live. Both stay at Tier 2. Keeping the suite off the PR path means the pipeline is never gated on live flake.

Testing

  • scripts/test, swiftformat, swiftlint strict, BrewUILint: all clean.
  • xcodebuild build-for-testing for the Brew-UI scheme.
  • Both plans verified through their generated xctestrun files.
  • scripts/test-e2e green locally.

Notes for reviewers

The E2E script allows code signing where scripts/test-ui disables it. With signing disallowed the runner keeps Apple's XCTRunner signature over contents the build has replaced, and macOS refuses to launch it as damaged. scripts/test-ui and the ui-test job carry the same latent defect, documented but not changed here.

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. All test written with AI in a sandvault sandbox but run manually by the developer.

graeme added 8 commits August 23, 2026 18:14
BrewApp.activate and BrewUITestCase's launched-app tracking both solve
problems that belong to running the app under XCUITest on macOS, not to
the stubbed wiring the harness happens to launch: a launch that leaves the
app windowless has an empty accessibility tree whatever it is wired to,
and a leaked process blocks the next test's launch outright.

A live suite launching production wiring needs both and can share neither
while one is private and the other only reachable through a scenario.
activate loses `private`, and tracking gets a name a subclass can call.
The deterministic suite proves our code behaves given an answer; nothing
yet proves the answers themselves still arrive. This harness launches the
app with no -uiTesting argument at all, so BrewApp.init() falls through to
.live() on both seams — the login shell resolves, the machine's real brew
runs, and URLSession.shared fetches the real catalogue.

The only injection is Homebrew's determinism environment, which is the
difference between a reliable canary and a flaky one: without
HOMEBREW_NO_AUTO_UPDATE an install can spend minutes updating the tap
first, and a test that hangs reads as a broken app. BrewCommandService
runs the subprocess with Environment.inherit and a login shell adds to
exported variables rather than clearing them, so they survive `-l -i`.

State is arranged and cleaned up by shelling out to real brew rather than
by driving the UI, so a broken arrange reads as a broken fixture instead
of as a red assertion three screens into the flow under test. That helper
is deliberately not the code under test: the app reaches brew its own way,
and the tests act through that path only. It resolves the executable the
way BrewExecutableLocator does, spelled again rather than imported because
a fixture actuator resolving brew differently from the app is a difference
worth being able to see.

Timeouts are an order of magnitude above the stubbed suite's because the
work behind them is real. They are generous on purpose: off the PR path,
a slow pass costs minutes, while a tight bound costs a canary that cries
wolf about the network.
Six canaries over install, uninstall, config, search, navigation and
console, reusing the page objects the stubbed suite uses so the only
difference between the tiers is what is behind them. If Homebrew changes
its JSON shape or CLI output and breaks a happy path, these go red while
the fixture-fed suite stays green — which is the entire point, since a
fixture cannot notice that it has started lying.

Assertions read shape, never values. A row exists, a HOMEBREW_VERSION
entry is present, presence and absence transition, output streamed and
ended in success. A canary that fails on a version bump gets muted within
a week, and a muted canary reports nothing.

Upgrade and doctor are absent by design: upgrade needs a deterministically
outdated installed package, which a live machine cannot guarantee without
brittle bottle pinning, and doctor's output is entirely machine-state
dependent so it has no stable happy path. Both stay at Tier 2, where they
are cheap and deterministic.

The mutating pair owns `hello` — no dependencies, pours in seconds — and
force-uninstalls it before and after every test, so the two are
order-independent and a failed run leaves no residue. Cleanup runs after
the app has been terminated, so it never contends with a running app for
Homebrew's lock.

Both live tests filter the list through the search field before asserting
on a row. A real machine's inventory is long and List virtualizes, so an
installed package's row may never render and would fail an assertion that
is otherwise correct. The stubbed suite never had to care; its fixtures
are short enough to render whole.
They mutate real machine state and depend on the network, so a red run
does not necessarily say anything about the change under review. Gating
every PR on that trains people to ignore the signal.

Brew-UI skips the two classes by name; Brew-E2E selects only them and
gives them what live work needs — serial execution, per-test allowances
measured in minutes, and retry-on-failure of one. One retry absorbs a
transient blip; more would start hiding a real contract break, which is
the only thing this suite exists to find.

Both plans hang off the existing Brew-UI scheme because xcodebuild
-testPlan can only reach a plan the scheme lists.
A canary nobody runs is a canary that reports nothing, so the suite gets a
schedule rather than only a plan: nightly on an ephemeral GitHub runner,
which ships Homebrew and has egress to formulae.brew.sh and ghcr.io, and
by hand before cutting a release — the moment you most want to know brew
has not shifted under the app. The release workflow is deliberately not
wired to it; that stays a decision someone makes.

Concurrency is keyed on the workflow rather than the ref, because two runs
would fight over one machine's brew state.

scripts/test-e2e mirrors scripts/test-ui, including its refusal to pass
-derivedDataPath, and leads with what it does to the machine it runs on:
this one installs and uninstalls a formula, and the script is the last
place that fact can be read before it happens.
The suite is one script away from anything running in this repo, and its
name is one letter from the deterministic one. An agent or a contributor
reaching for "the UI tests" must not discover the difference by watching a
formula get uninstalled, so AGENTS.md says which is which and that this
one is never a quality gate.

The memory entry keeps the decisions that the code cannot show: why
upgrade and doctor are excluded, why `hello` is the canary and nothing may
depend on it, why assertions read shape rather than values, and why the
live tests filter a list the stubbed ones never had to.
The suite could not run at all: macOS put up "BrewUITests-Runner is
damaged and can't be opened", and xcodebuild sat there until it gave up
with "The test runner hung before establishing connection." The dialog is
the cause and the hang is only the consequence — a runner Gatekeeper
refuses to start never connects.

CODE_SIGNING_ALLOWED=NO, copied here from scripts/test-ui, does not
produce an unsigned runner. BrewUITests-Runner.app is a copy of Xcode's
XCTRunner.app template, which arrives signed by Apple; the build inserts
our .xctest into its PlugIns directory and then, with signing disallowed,
skips the CodeSign step. What launches is a bundle carrying Apple's
signature over contents that no longer match it, which codesign reports
as "code has no resources but signature indicates they must be present".

Allowing signing with identity "-" ad-hoc signs the runner as
sh.brew.BrewUITests.xctrunner with a valid seal. Ad-hoc needs no identity,
team or provisioning profile, so this is not a local-only accommodation —
it works on a bare CI runner too.

scripts/test-ui and the ui-test CI job still carry the flag and the same
latent defect; TROUBLESHOOTING.md now says so, so the next person to meet
this dialog reads it rather than re-derives it.
The live suite's comments restated what the code says and argued its own
design at length. What survives is the part a reader cannot recover: that
a login shell keeps exported variables, that a real inventory virtualizes
so an installed row may never render, that cleanup runs after the app is
terminated to stay clear of Homebrew's lock, and why the runner must be
signed. The reasoning behind the rest is in the commits and in
E2E/README.md, which is where it belongs.
graeme added 3 commits August 23, 2026 19:15
Two gaps in a suite that retries. A failure's message says an element was
missing; it cannot say whether a dialog was covering the app, which is how
the last one actually failed. And a retry that turns red into green leaves
a run that reads as clean.

BrewUITestCase now attaches a screenshot of the whole screen to every
issue it records, so the picture lands in the result bundle CI uploads.
It goes in as PNG data rather than as the XCUIScreenshot, which cannot
cross out of the main actor into this nonisolated override. Both the
attachment and the plan keep screenshots always: the interesting one
belongs to a failed attempt of a test that ultimately passed, and
deleteOnSuccess prunes exactly that.

The plan now retries a failing test twice rather than once, and
annotate-flaky-tests reads the result bundle afterwards and names any test
that needed more than one run to pass — a ::warning:: annotation on GitHub,
plain text locally, never a failure by itself. It also tells the workflow
to keep the bundle for a green-but-retried run, so the evidence the
annotation points at still exists.
Nightly cron only fires on the default branch, so the suite would have
gone unrun until after it merged, and the first real evidence it works
would have arrived on main. Moving it to pull_request means it is exercised
where it can still be fixed.

It stays a separate job rather than joining the deterministic suite's:
Brew-UI still skips these tests by name, so a live failure reads as a
live failure and never as a broken fixture-backed test. Concurrency now
keys on the pull request, since queueing every PR behind one run only made
sense while the group modelled a single shared machine.

The workflow file loses its `_nightly` name, and the docs that promised a
nightly schedule now say what actually happens.
The suite documented itself at essay length: paragraphs restating what the
next line does, and reasoning that belongs in a commit message. Reading a
page object should not mean reading an argument for page objects.

What is left is one line wherever one line does: the surprises (a
`.searchable` field that cannot carry an identifier, `List` row text
arriving as accessibility value rather than label, an Uninstall button
below the fold, a fixture bucket that must be absent rather than empty)
and the type-level sentence saying what each file is for. Everything that
merely restated a declaration is gone.

96 comment lines removed across the suite; no code changed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant