Skip to content

Test/playwright UI suite - #582

Open
duderoot wants to merge 9 commits into
mogenius:mainfrom
duderoot:test/playwright-ui-suite
Open

Test/playwright UI suite#582
duderoot wants to merge 9 commits into
mogenius:mainfrom
duderoot:test/playwright-ui-suite

Conversation

@duderoot

@duderoot duderoot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

TL;DR
According to #559 the UI is expecting to change and to grow by receiving new features. With the e2e test with playright we have a bigger confidence into changes 😜

Closes #581.

What

The harness that issue asks for, built as described: the real index.html and
pages/logs.html served through a stand-in for the Go server, /api/v1/* stubbed per
spec with page.route, fixtures mirroring the Go payloads, and everything under
tests/ui so nothing lands in the image.

No production code is touched. The diff is tests/ui/**, three recipes in the
Justfile, five ignore lines, and two lines of CLAUDE.md (the tests/ui tree and
just test-ui under Verification). Nothing about the Go build, the image, the chart
or the API changes, and neither page was modified to make it testable.

just test-ui                  # the suite, headless
just ui-dev                   # the frontend with mock data on :8098, no cluster
just test-ui-baseline <rev>   # today's specs against an older revision of the pages

What it covers

16 specs, about 8 seconds, one Chromium project at 1280x900. They pass under
BASE_PATH=/renovate as well, which until now was only exercised by Go unit tests.

spec file what it pins
jobCardExpansion.spec.mjs cards open per JOB_CARDS_EXPANDED_BY_DEFAULT; a collapse the user made survives the 30s auto-refresh; the bulk controls act only on the jobs the stat filter and the search box leave visible
stickyToolbar.spec.mjs the dashboard's filters and search stay on screen while the brand strip scrolls away, stay usable from the bottom of the page, and sit below the overlay that dismisses a card's execution-options popover
logsFiltering.spec.mjs the header counters follow the page's own level thresholds; the level filter narrows the list, persists, and yields to an explicit ?levels=; search keeps the matching entries, highlights them, and opens the rows whose match is hidden below the message
stickyLogsToolbar.spec.mjs the same three properties for the logs page's bar, including its level dropdown opening over the rows it floats above

How it works

  • The real pages, served the way the operator serves them. staticFrontendServer.mjs
    mirrors serveHTML and registerUiRoutes, including the <base href> and
    window.__BASE_PATH__ spliced into <head>. A plain file server would silently test
    a page that never ships.
  • The API is stubbed per spec with page.route, so each test owns its data and can
    reach states a cluster produces rarely.
  • Fixtures mirror the Go payloadsui.RenovateJobInfo,
    crdManager.RenovateProjectStatus, and the SSE frames getRenovateJobLogs writes.
    PROJECT_STATE_VARIANTS is a catalogue of every project state the dashboard renders
    differently, so a spec can name the states a filter should keep instead of counting
    rows. When a field is added to either Go struct, it needs adding there too; the README
    says so, and the tests are only as honest as those payloads.
  • /api/v1/logs is an event stream, so a spec serves the whole body in one
    route.fulfill and the browser's EventSource parses it frame by frame exactly as it
    would a live stream. just ui-dev writes the same frames on a timer instead, so
    clicking a log link shows the page streaming rather than a finished list.
  • It lives outside src/static because the Dockerfile copies that tree into the
    image, so anything placed there would ship and be served.

Does it actually catch anything?

That is the question a test-only PR has to answer, so test-ui-baseline exists to
answer it: it serves both pages from any revision while keeping today's specs.

Run against 9e894f1^ — the revision before the toolbar change of #572 landed:

5 failed
  3x sticky logs toolbar
  2x sticky dashboard toolbar
11 passed
  4x job card expansion
  6x logs filtering
  1x sticky dashboard toolbar  (the popover/stacking spec)

That split is the point. The five failures are the specs that pin what #572 changed —
they fail on the revision before it and pass on main. The eleven passes are specs
about behaviour that #572 did not touch, so they hold on both revisions and would have
caught a regression in it.

The one sticky spec that passes on both is deliberate and worth keeping: it guards a
regression that pinning the bar could introduce rather than proving a fix. With the
bar in the same stacking context as a card's execution-options popover, a click on a
stat badge would otherwise filter the list and leave the popover hanging over cards it
no longer belonged to.

Decisions worth a second opinion

  • Log rows are located by their message and their level label, not by a
    data-testid. A row has no role of its own, and I did not want to add test hooks to a
    production page for the sake of the suite. The cost: the level dropdown repeats those
    same words, so it has to be closed before counting rows — documented in the README and
    handled by the page object. If you would rather have data-testid="log-row" in the
    markup, that is a one-line change and I will make it.
  • One Chromium project at a fixed 1280x900. A fixed viewport is what makes the
    above-the-fold assertions mean the same thing on every machine. The phone layout is
    not covered.
  • Two ways to produce a payloadpage.route stubs in specs, mockOperatorApi.mjs
    for just ui-dev. They share the fixture builders but not the wiring. The alternative
    was one mock server driving both, which would have made every spec depend on global
    state.

Cost

npm ci plus a Chromium download on the first run of just test-ui; both are cached
afterwards. node_modules, test-results and the reports are git-ignored. just build,
just test-unit, just test-helm and just generate are unaffected — nothing in the
Go pipeline runs this.

Not in this PR

Both are named as out of scope in #581 and I would rather they be judged on their own:

  • CI. No workflow runs just test-ui today; go-test.yaml builds and tests Go only.
    Wiring it in, and deciding whether it gates merges, is the obvious next step.
  • A second viewport project, so the phone layout of the pinned toolbars is exercised
    rather than spot-checked by hand.

About the commit history

The branch is where the two toolbar fixes were developed, so fix(ui): pin the dashboard filters… and fix(ui): pin the logs filters… appear in its history. Both are already
on main via #572, so they contribute nothing to this diff — git diff origin/main...
is tests only. There are also two merges from main. Happy to rebase or squash the
history into a single test(ui): commit if you prefer a clean list.

Checklist

  • One logical change — the test harness and nothing else
  • Title follows Conventional Commits
  • Branch prefixed with the change type — test/playwright-ui-suite (test is a
    Conventional Commits type; CONTRIBUTING.md lists four prefixes and not this one)
  • Every commit carries a Signed-off-by (DCO)
  • AI-assisted commits carry a Co-Authored-By naming the model
  • No documentation impact — no new config, CRD, Helm value or user-facing setting;
    contributor docs are in tests/ui/README.md and two lines of CLAUDE.md

duderoot and others added 8 commits August 4, 2026 13:13
The frontend had no automated coverage. It is a client-side React app that gets
everything from /api/v1/*, so the suite serves the real index.html and stubs the
API with page.route — no cluster, no Go build, and no changes to the page to make
it testable.

Four specs cover the job-card expansion behaviour: cards open according to
JOB_CARDS_EXPANDED_BY_DEFAULT, a collapse the user made survives the 30s
auto-refresh, and the bulk controls act only on the jobs the stat filter and the
search box leave visible.

staticFrontendServer.mjs mirrors serveHTML and registerUiRoutes rather than being
a generic file server: the frontend only learns its sub-path from the <base href>
and window.__BASE_PATH__ the Go server splices into <head>, so serving the files
plainly would exercise a page the operator never ships. BASE_PATH=/renovate runs
the whole suite under a sub-path.

The tests live outside src/static because the Dockerfile copies that tree into the
image, so anything placed there would ship to production and be served.

Three recipes:

  just test-ui                  run the suite
  just ui-dev                   dashboard with mock data on :8098, no cluster
  just test-ui-baseline <rev>   run the current specs against another revision's
                                index.html, to confirm a spec fails without the
                                change it covers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: duderoot <catalin.patruica@xlab-iq.de>
A dashboard with a realistic number of projects is many viewports tall. The stat
badges, the search box and the bulk expand/collapse controls all sat in the
normal document flow, so scrolling into the job list took every one of them off
screen: narrowing a long list meant scrolling back to the top first, filtering,
then finding the place again.

They now live in their own bar pinned to the top of the viewport, while the brand
strip above it scrolls away like ordinary content. SiteHeader grew a
hasBottomMargin prop so the dashboard can put that bar flush underneath it; every
other page keeps the margin it had.

The badges sit in one horizontally scrollable row rather than the wrapping grid
they were in — as a four-row grid on a phone the pinned bar would have eaten the
viewport it is pinned to. For the same reason the two copies of the search row
(one for sm and up, a mobile-only duplicate further down the page) collapse into
a single row, with short Expand/Collapse labels below sm so the search box stays
usable. A shadow appears once the page has scrolled, so the bar lifts off the
cards instead of carrying one while it sits in place.

Pinning the bar puts it in the same stacking context as a job card's
execution-options popover and the full-page overlay that dismisses it, so the
overlay moves above the toolbar: cards < toolbar < overlay < menu. Otherwise the
first click on a badge would filter the list and leave the popover open, hanging
over cards it no longer belongs to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: duderoot <catalin.patruica@xlab-iq.de>
Three specs for the bar the previous commit pinned to the top of the viewport.

Two pin the behaviour it exists for: on a six-job, twenty-project dashboard the
brand strip leaves the viewport while the toolbar stays at y=0 with its badges,
search box and bulk controls on screen, and both filtering and collapsing work
from the bottom of the page without scrolling back up.

The third covers the stacking order. It clicks where a stat badge is while a job
card's execution-options popover is open and asserts the popover closes with
nothing filtered — the dismiss overlay has to stay above the toolbar. It needs a
raw mouse click rather than locator.click(), which refuses to fire at a covered
element, which is the state under test. test-ui-baseline cannot show this spec
has teeth, since a revision without the toolbar passes it for the wrong reason:
point INDEX_HTML_PATH at a copy with only the z-index classes reverted instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: duderoot <catalin.patruica@xlab-iq.de>
The suite only knew about the dashboard, so the page a user lands on to find out
why a project failed had no coverage at all — and no way to be opened by hand
either, since `just ui-dev` answered every /api/v1 call except the one the logs
page makes.

/api/v1/logs is an event stream rather than a JSON document, so the fixtures build
the frames getRenovateJobLogs writes: one `data:` frame per log line, closed by an
`event: done`. A spec serves the whole body in a single route.fulfill and the
browser's EventSource parses it frame by frame exactly as it would a live stream;
the mock server writes the same frames on a timer instead, so clicking a log link
under `just ui-dev` shows the page streaming rather than a finished list.

Six specs pin what the page does with a stream once it has arrived: the header
counters follow the page's own level thresholds, the level filter narrows the list
and is remembered for the next visit while an explicit ?levels= overrides it, and
the search box keeps the matching entries, highlights what matched, and opens the
rows whose match is hidden below the message.

test-ui-baseline now swaps pages/logs.html alongside index.html, so a logs spec
can be shown to fail against the revision before the change it covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: duderoot <catalin.patruica@xlab-iq.de>
A Renovate run logs thousands of lines, so the logs page is far taller than the
dashboard ever gets. The search box, the level filter and the copy/download
buttons all sat in the normal document flow, which meant the moment a user
scrolled to the error they came for, every control that could narrow the list was
gone — and narrowing it is the whole reason to open the page. The error and
warning counters went with them.

They now live in the same kind of pinned bar the dashboard got: the counters in
one horizontally scrollable row, the search box and the three controls in the row
below, with the brand strip above scrolling away like ordinary content. The
streaming/completed indicator moves up there too — whether the log is still
growing belongs next to the counters that keep changing with it, not on a line
that scrolls out of sight. What stays behind in the page body is the heading and
the project name, which say what you are looking at, not what you can do to it.

Below sm the Copy and Download labels drop to their icons so the search box keeps
a usable width; both buttons carry an aria-label, so their accessible name no
longer depends on a label that may be hidden.

The bar itself is now a component. The dashboard's was written inline — its scroll
listener, its stuck state and the six classes that make it a bar — and a second
copy on the logs page would have been the kind of thing that drifts apart one fix
at a time. StickyToolbar owns all of it, including the z-30 that keeps it above
the page content and below the overlays that dismiss a popover, so a page only
says what goes in the bar. The dashboard renders identical markup through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: duderoot <catalin.patruica@xlab-iq.de>
Three specs for the bar the logs page now pins to the top: it stays put while the
brand strip scrolls away, the search box narrows the log from wherever the reader
already is rather than after a trip back to the top, and the level filter opens
over the rows it now floats above — that last one only passes because the bar wins
the stacking order, and a click on the checkbox would otherwise land on a log row.

All three fail against the revision before the change, while the six specs for the
page's filtering behaviour pass on both, which is the split that says the fix is
what they are pinning down and not the page in general.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: duderoot <catalin.patruica@xlab-iq.de>
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.

Browser tests for the web UI: the frontend is the only part of the operator nothing verifies

1 participant