Skip to content

Flaky: UserControllerSpec > User list in grails-test-examples/scaffolding submits the login form without a session cookie #16217

Description

@codeconsole

grails-test-examples/scaffolding fails intermittently in CI, on four occasions between 24 and 25 August 2026, on four unrelated branches. The root cause is not yet known; this issue records what is established so far.

The test

com.example.UserControllerSpec > User listgrails-test-examples/scaffolding/src/integrationTest/groovy/com/example/UserControllerSpec.groovy

void "User list"() {
    when: 'an unauthenticated user requests the user list and signs in when prompted'
    via(UserListPage)
    at(LoginPage).login()

    then: 'the saved request redirects to the user list'
    at(UserListPage)
    scaffoldTable
}

It fails inside LoginPage.login(), on the wait that follows the click:

void login(String username = 'test@grails.org', String password = 'letmein') {
    this.username = username
    this.password = password
    loginButton.click()
    waitFor { title != pageTitle && $('input', name: 'username').empty }   // times out here
}
geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
    at com.example.pages.LoginPage.login(LoginPage.groovy:39)
    at com.example.UserControllerSpec.User list(UserControllerSpec.groovy:48)

    Caused by: Assertion failed:

    title != pageTitle && $('input', name: 'username').empty
    |     |  |         |
    |     |  |         false
    |     |  'Please sign in'
    |     false
    'Please sign in'

That is the trace from #16150, #16184 and #16209, which are identical to each other: the same
condition, the same thirty seconds, LoginPage.groovy:39 in login_closure1, and
UserControllerSpec.groovy:48.

The fourth, #16215, differs in one respect: LoginPage.groovy:43, in login_closure2. That branch
carries a proposed change to this page object which adds lines above the wait, so the same wait on
the same condition reports a different line and closure. It is the same failure, not a different
one - and worth noting because it shows the wait that fails is the one after the click, on a branch
where the form is verified as filled before clicking.

Occurrences

Date (UTC) PR Job Duration
2026-08-24 05:34 #16150 Build Grails with Groovy snapshot (shard 2) 55m
2026-08-24 05:34 #16184 Functional Tests (Java 25, indy=false) 59m
2026-08-24 21:35 #16209 Functional Tests (Java 25, indy=false) 51m
2026-08-24 23:29 #16215 Functional Tests (Java 21, indy=true) 43m

None of those four branches touches this application, Spring Security, or Geb. It has been seen on Java 21 and Java 25, with indy on and off, and in two different workflows. In each run the other two specifications of the same application - BookControllerSpec and UserCommunityControllerSpec, which sign in the same way - passed.

What the browser was looking at

@ContainerGebConfiguration(reporting = true) means Geb captures the page at the failure. Three of
the four runs upload it - #16184, #16209 and #16215 - and those three are the same to the byte. The
#16150 run is in a workflow that uploads no Geb report, so there is no capture for it:

  • 1089 bytes, <title>Please sign in</title> - Spring Security's generated login page
  • both fields empty, showing their placeholders
  • the focus ring still on the username field, where autofocus puts it on load
  • no error: not /login?error, no "Bad credentials" banner
  • a _csrf hidden field present

Two things follow. The page was loaded after the credentials were typed, because a page that had been typed into would hold the values and would have the focus on the second field. And the request that produced it was not a rejected authentication attempt, because that renders ?error with a banner.

What the failure is not

Determined by injecting faults locally into the same test and comparing the captured page:

Injected fault Page produced Matches CI
Session cookie deleted between filling and submitting 1089 bytes, login page, no error, autofocus yes, byte for byte
Valid session, _csrf field overwritten with a stale value 283 bytes, Whitelabel Error Page (403) no

So the form reaches the server without a session cookie, rather than with a stale or mismatched CSRF token. Missing session and wrong token are answered differently: with no session Spring Security treats the caller as anonymous and returns the login page, while a wrong token on a live session is denied with a 403.

It is also not the credentials or the user data: UserService.loadUserByUsername looks the user up by email, and a lookup failure would produce ?error, which is absent.

What is still unknown

Why the browser has no session cookie when the form is submitted. The specification clears cookies in setup():

void setup() {
    clearCookiesQuietly()
}

and ContainerGebSpec holds @Shared static GebTestManager testManager, so the browser is shared between specifications in a worker. Whether the cookie is cleared, never set, scoped to a different host than the one the form posts to, or lost some other way is not established.

A consequence worth noting for anyone attempting a fix: if the session is genuinely gone, Spring Security's saved request goes with it, so simply submitting the form again is not enough - the login then succeeds but lands on / rather than the user list, and the specification fails on at(UserListPage) instead. Confirmed locally.

Reproducing

Not reproduced on demand. Four consecutive local runs of :grails-test-examples-scaffolding:integrationTest passed. The failure signature can be produced deliberately by deleting the browser's cookies between filling the form and clicking, which is how the comparison above was made.

Suggested next step

Capture the browser's cookies and currentUrl into the Geb report when that wait fails. One further occurrence would then distinguish "no cookie at all" from "a cookie scoped to another host", which is what the fix depends on.

Other CI flakes tracked separately

Different tests and different mechanisms; listed so that anyone working through CI flakiness can see what else is open.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions