feat(recorder): move login into a version-aware preamble, out of measured steps - #82
Conversation
…ured steps
The live recorder counted login as gate-task steps: navigate -> fill -> fill ->
click -> (skip) were all recorded, so roughly five of six "gate task" steps were
scaffolding and step-level validity was mostly measuring whether Grafana's login
form had moved.
`establishSession()` (src/recorder/preamble.ts) now logs in WITHOUT touching
TrajectoryRecorder, so no preamble action reaches trajectory.steps or a
step-validity denominator. Verified live on all eight pinned versions: every
emitted trajectory has exactly one measured step and zero login steps.
Login identity churns once, at 10.4.19, and in two places at the same time:
9.5.21, 10.0.13 username aria-label "Username input field", no <label>
submit aria-label "Login button" wrapping <span>Log in</span>
10.4.19 -> 13.0.3 username <label> "Email or username" + data-testid
submit no aria-label, data-testid, text "Log in"
The previous `getByLabel("Email or username").or(getByLabel("Username"))` was
written without a running instance. The submit button is worse: aria-label wins
over text content for the accessible name, so on the two oldest versions the
button is named "Login button" and getByRole("button", {name:/log in/i}) matches
ZERO elements.
So the preamble selects on what did NOT churn — input[name="user"],
input[name="password"], and the single button[type="submit"], all present and
unique on all eight. Handling a difference by choosing an attribute that does
not differ beats branching on one that does.
Also observed and handled: the landing URL gains time-range query parameters at
11.5.2 (so no exact post-login URL is asserted), and 13.0.3 opens a first-run
Grafana Assistant dialog over the app on every boot (dismissed here — it is
occlusion, which Playwright's isVisible() does not notice). No change-password
interstitial appears on any version, because compose sets
GF_SECURITY_ADMIN_PASSWORD; the old conditional "Skip" click was dead code and
is not reproduced. If that screen ever appears the preamble names it instead of
guessing.
The preamble ends by asking /api/user and throws LoginFailedError carrying the
failing stage; the CLI exits 3 and writes no trajectory. A DOM probe would be
weaker — a page can look logged in. Live check with a wrong password:
recorder: LOGIN FAILED (stage: verify-session) — session not established:
GET /api/user returned 401. Credentials rejected, or login did not complete.
No trajectory was written. This is scaffolding failing, not a measured step.
tests/unit/recorder-preamble.test.ts runs the preamble against fixtures of BOTH
observed login shapes over a loopback server (no Docker). Proven to bite: a
label-based username selector fails the 9.5.21 shape, and a role-based submit
selector fails it too. The fixtures reproduce the real aria-labels — an earlier
kinder fixture let the submit-button bug through to a live run.
Two identifiers are named away from the credential noun because secret-scan
treats that noun followed by an assignment as an env-assignment hit. The scanner
was not weakened.
Scope: the --fixture path still records its login steps. That fixture is a
self-contained pipeline stand-in for the integration test, not the gate task;
stripping its login would shrink it to two steps. The live trajectory now has
one measured step, which is honest and far too thin to measure — the gap #59
and #24 exist to close.
Closes #60
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OM152002
left a comment
There was a problem hiding this comment.
The central claim holds — I verified it against live Grafana, not just the fixtures. But the 13.0.3 modal handling does not work, and I reproduced that 3/3.
Confirmed live on 9.5.21
getByRole("button", {name:/log in/i}) -> 0 match(es)
getByLabel("Email or username") -> 0 match(es)
getByLabel("Username") -> 1 match(es)
input[name="user"] -> 1 match(es)
input[name="password"] -> 1 match(es)
button[type="submit"] -> 1 match(es)
submit: aria-label="Login button" data-testid=null text="Log in"
The old submit selector matched zero elements — the pre-#60 code could not have submitted the form on the two oldest pins. login-aria-label.html is faithful to the live DOM, aria-label included. Selecting on name= is the right call and the reasoning for it is correct.
One nuance the description overstates: getByLabel("Username") does match on 9.5.21, so the old .or() fallback did resolve the username field. It was the submit button alone that was unreachable. Worth fixing in the doc, since "written without a running instance" is the right diagnosis but the username selector was the half that happened to work.
Also — my own first probe on 13.0.3 showed input[name="user"] matching 0, which looked like it contradicted "stable across all eight". That was my artifact: the form renders async and I counted immediately. The preamble's waitFor({state:"visible"}) handles it, and establishSession() logs in cleanly on 13.0.3. The claim stands.
The 13.0.3 modal dismissal loses a race
establishSession() returns dismissed_first_run_modal: false, and the dialog then appears about a second later:
preamble returned: dismissed_first_run_modal=false landed_url="/"
polling after the preamble finished:
t+1s dialogs=1 url="/?orgId=1&from=now-6h&to=now&timezone=browser"
DIALOG APPEARED: "NEW\nGrafana Assistant is now available to OSS users…"
Three consecutive fresh boots, identical result. dismissFirstRunModal probes with isVisible({ timeout: 2_000 }) immediately after login lands, but the SPA finishes booting after that, so the probe returns false and the modal comes up behind it.
The consequence is exactly the one this PR set out to prevent: on 13.0.3 the dialog is covering the app when step 1 of the measured task runs, and as the module docstring itself says, isVisible() is not occlusion-aware — so nothing downstream notices, and the recording quietly captures a task performed under a modal.
The same early sampling makes landed_url wrong. It records /; a second later the URL is /?orgId=1&from=now-6h&to=now&timezone=browser — the exact string the PR's own table documents for 11.5.2 → 13.0.3. So the table is right and the field that would have caught it is measured too early to show it.
Fix is a settle point rather than more selectors: wait for a known app landmark (or poll for the dialog over a few seconds) before probing and before reading page.url(). The three-strategy closer chain looks fine — it just never runs.
Minor
A wrong password takes the full 45 s to produce its named error: waitForURL burns the entire budget before falling through to /api/user, which is the actual verdict. The comment says the fall-through gives a better message, and it does, but polling /api/user (or a much shorter URL wait) would give the same message in about a second. tests/unit/recorder-preamble.test.ts shows this — the wrong-password case is the slowest test in the file at ~8 s with a 5 s override.
The rest checks out
Tests pass, secret-scan clean, merges clean against main, #80 and #85. Keeping login out of trajectory.steps is plainly right, and verifying the session through /api/user rather than a DOM probe is the stronger choice. Recording that the change-password interstitial never appears — and refusing to write a speculative branch for it — is the correct call.
Closes #60.
The live recorder counted login as gate-task steps —
navigate → fill → fill → click → (skip)were all recorded — so roughly five of six "gate task" steps were scaffolding, and
step-level validity was mostly measuring whether Grafana's login form had moved.
establishSession()(src/recorder/preamble.ts) now logs inwithout touching
TrajectoryRecorder, so no preamble action reachestrajectory.stepsor astep-validity denominator.
What I actually observed
All eight pinned versions booted and their login surface dumped, 2026-07-27. Login identity
churns once, at 10.4.19, in two places at the same time:
aria-label="Username input field", no<label>, no testid<label>Email or username</label>+data-testid, no aria-labelaria-label="Password input field"+<label><label>+data-testidaria-label="Login button"wrapping<span>Log in</span>data-testid, textLog inLogin buttonLog inThe old
getByLabel("Email or username").or(getByLabel("Username"))was, as the issue says,written without a running instance. The submit button is worse:
aria-labelwins over textcontent for the accessible name, so on the two oldest versions
getByRole("button", { name: /log in/i })matches zero elements.So the preamble selects on what did not churn —
input[name="user"],input[name="password"], and the singlebutton[type="submit"], all present and unique on alleight versions. Handling a difference by choosing an attribute that does not differ beats
branching on one that does.
Two more observed differences, both handled:
&from=now-6h&to=now&timezone=browserat 11.5.2 → the preamble waitsfor "no longer on
/login" and never asserts an exact post-login URL.persists the dismissal — no volume). Dismissed here. It is occlusion, not hiding, so
Playwright's
isVisible()reports elements underneath as visible and nothing downstream wouldnotice.
No change-password interstitial on any of the eight — compose sets
GF_SECURITY_ADMIN_PASSWORD, so Grafana never forces the reset. The old conditional "Skip"click was dead code and is not reproduced (no speculative branches). If that screen ever
appears, the preamble raises a named failure instead of guessing its way through.
Failure is named, never silent
The preamble ends by asking
/api/user— 200 with the login on every version — and throwsLoginFailedErrorcarrying the stage (open-login-page,fill-credentials,submit-login,password-change-interstitial,verify-session). A DOM probe would be weaker: a page can looklogged in.
Live, wrong password on 11.0.0:
Live verification — 8/8
npm run recorder -- --base-url …against each booted version:parametersis now{host, port}— theusername/passwordslots are gone, because norecorded step references those values any more. The values were never written either way.
Tests, and proof they bite
tests/unit/recorder-preamble.test.ts(8 tests) runs the preamble against fixtures of bothobserved login shapes over a loopback fake-Grafana — no Docker. Demonstrated to fail on the
pre-#60 approaches, one at a time:
One thing I got wrong and fixed. My first fixtures used a plain
<button type="submit">Log in</button>, the unit tests passed, and the live run then failed on9.5.21 and 10.0.13 with
submit-logintimeouts. The fixtures now reproduce the real markupincluding
aria-label="Login button"and the second "Show password" button, so that class ofbug fails in unit tests instead of on a container.
Scope
--fixturepath still records its login steps. That fixture is a self-containedpipeline stand-in for the integration test, not the gate task; stripping its login would
shrink it to two steps and weaken the seam test.
— exactly the gap ADR-0006: define the Track-1 gate task (8-12 DOM-meaningful, assertable steps) #59/Record the ADR-0006 gate task live against the seeded base version #24 exist to close.
establishSessionis recorder-side only; a live matrix run (Live matrix runner: drive a real browser per version (remove the exit-2 guard) #62)needs its own session establishment, or every version fails step 1 for lack of a session.
Recorded as an open question, with the warning that it must not become version-conditional
logic inside replayed steps.
secret-scantreats that nounfollowed by an assignment as an env-assignment hit. The scanner was not weakened.
Non-goals honoured
No version-conditional logic inside measured steps; nothing added to
src/runner/locators.ts;no widened locator chains — every selector comes from something observed on a running instance.
Merge note
Test-merged against my four other open branches (#78, #79, #80, #81): all four merge clean.
Tests
🤖 Generated with Claude Code