Stop a dropped click from failing the runner pause/resume spec
What does this MR do and why?
Fixes the rank 1 pipeline-blocking flaky test,
spec/features/groups/runners/owner_manages_runners_spec.rb, tracked in
https://gitlab.com/gitlab-org/quality/test-failure-issues/-/work_items/43855.
The failure is a dropped click, not a timing coincidence.
Root cause
pauses and resumes runner does this:
click_button "Pause"
expect(page).to have_text "Paused"
click_button "Resume"
expect(page).not_to have_text "Paused"When the pause mutation resolves, Apollo writes paused: true to the cache.
That write renders the row before the mutate promise's continuation clears
RunnerPauseAction's loading flag, so the toggle goes:
Pause (busy) -> Resume (busy) -> Resume (available)The Paused badge appears at the middle state. That is where
expect(page).to have_text "Paused" unblocks, and where click_button "Resume"
then clicks.
A busy Pajamas button is not natively disabled. It renders
aria-disabled="true" and computedListeners deletes its click listener
while isDisabledOrLoading (@gitlab/ui, components/base/button/button.js).
Capybara's :button selector filters on the native disabled property, so it
matches that button, clicks it, and the event is discarded. No resume mutation
is sent. Nothing is in flight, so nothing changes the row again, and
expect(page).not_to have_text "Paused" polls until it times out.
That matches the reported signature, including the full-timeout wait:
expected not to find text "Paused" in "Online Paused Idle #7 (F4gsq... Project My runner2 ..."
Timeout (30s) reached while running a waiting Capybara finder.The window is one render long and Capybara usually takes longer than that to come back and click, which is why this only lands a couple of times a fortnight.
The fix
Wait for the toggle to be available, not merely present:
click_button "Resume", aria_disabled: falsearia_disabled is a new node filter on Capybara's :button selector. Capybara
already ships a disabled: filter, but it reads the native property, which
GlButton only sets while accessible_disabled_button is off. aria-disabled
is set in both flag states, so the filter expresses the condition that
actually matters and keeps working after that flag rolls out.
The filter is generic, and this trap is not specific to runners: any spec clicking a Pajamas button that can be busy is exposed to it.
How to set up and validate locally
bundle exec rspec \
spec/features/groups/runners/owner_manages_runners_spec.rb \
spec/features/admin/runners/admin_manages_runners_spec.rb \
spec/features/projects/runners/maintainer_manages_project_runners_spec.rb \
-e "pauses and resumes runner"Reproducing the failure
The window is real but short. Widen it by adding a delay before
RunnerPauseAction clears its flag:
// app/assets/javascripts/ci/runner/components/runner_pause_action.vue
} finally {
await new Promise((resolve) => { setTimeout(resolve, 3000); });
this.loading = false;
}With that in place, and the example run under both flag states:
| resume click | result |
|---|---|
click_button "Resume" |
2 examples, 2 failures, both expected not to find text "Paused" |
click_button "Resume", aria_disabled: false |
2 examples, 0 failures |
Both rows cover accessible_disabled_button enabled and disabled.
Without widening the window, clicking on the first render that offers the
button reproduces it on master every time:
page.execute_script(<<~JS)
new MutationObserver(() => {
const btn = document.querySelector('[aria-label="Resume"]');
if (btn && !window.__done) { window.__done = true; btn.click(); }
}).observe(document.body, { subtree: true, childList: true, attributes: true });
JSA MutationObserver that only records shows the toggle rendering
[{label: "Pause", ariaDisabled: "true"}, {label: "Resume", ariaDisabled: "true"}]
before it becomes available.
Why not make the button natively disabled
Adding :disabled="loading" to RunnerPauseButton also fixes it today, because
with accessible_disabled_button off GlButton still emits the native
attribute. Measured:
accessible_disabled_button |
native disabled while busy |
Capybara waits |
|---|---|---|
| off (today) | yes | yes |
| on (after rollout) | no | no |
So it would stop working when groupdesign system enables that flag, with nothing in the diff to explain the regression, and it reintroduces the unfocusable disabled button the flag exists to remove.
Relationship to other work
!249237 (closed) attributes this flake to a different mechanism: a runner list read, started implicitly by the pause mutation's cache write, landing after a subsequent resume mutation and overwriting it.
That implicit read is real. @apollo/client 3.5.10 re-runs a cache-and-network
or network-only watch query over the network on every cache broadcast
(QueryInfo#setObservableQuery calls a bare oq.reobserve()), documented in
!118580 (comment 1366339919) and
upstream in https://github.com/apollographql/apollo-client/issues/6760.
It does not appear to be what fails this spec. Delaying every runner list read
by 2s (11 reads in a single example) does not reproduce the failure, because
not_to have_text succeeds the moment the badge clears, so a late stale
response arrives after the expectation has already passed. Removing the
implicit read also would not close the busy window, which comes from the
mutation's own loading state. Worth addressing separately, on its own merits.
Follow-up worth considering
A button that reads Resume while silently discarding clicks is a UI bug in its own right. A user who clicks as soon as the Paused badge appears gets no mutation, no feedback, and a row that stays wrong until reload. Left out here to keep the flake fix small.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.