Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 91 additions & 2 deletions docs/gate/recorder.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,81 @@ ADR-0003 / B1 test-bed was **not merged** at capture time. Default site identity

Re-label `site_key` / `testbed_version` when B1 pins a version matrix.

## Session preamble — logging in is NOT a measured step

`establishSession()` in [`src/recorder/preamble.ts`](../../src/recorder/preamble.ts) logs the
browser in **without touching `TrajectoryRecorder`**, so no login action reaches
`trajectory.steps` and **no preamble step ever enters a step-validity denominator**. Before
issue #60 the live CLI recorded navigate → fill → fill → click → (skip) as measured steps, so
roughly five of six "gate task" steps were login scaffolding, and step-level validity was mostly
measuring whether Grafana's login form had moved.

The distinction is load-bearing:

- **Login is scaffolding.** It gets the browser into a state where the task can begin. Making it
version-robust is legitimate.
- **The task is the measurement.** Version-conditional fallbacks *inside* measured steps would
launder churn out of the gate number. Do not copy this pattern into the recorded task, and do
not add version fallbacks to `src/runner/locators.ts` — replay must fail honestly when a
locator stops resolving, because that failure is the datum.

### What was observed, and on which versions

All eight pinned versions were booted and their login surface dumped on **2026-07-27**:
9.5.21, 10.0.13, 10.4.19, 11.0.0, 11.5.2, 12.0.0, 12.2.1, 13.0.3. Login identity churns once,
at **10.4.19**, and it churns in two places at the same time:

| | 9.5.21, 10.0.13 | 10.4.19 → 13.0.3 |
| --- | --- | --- |
| username field | `aria-label="Username input field"`, no `<label>`, no testid | `<label>Email or username</label>`, `data-testid="data-testid Username input field"`, no aria-label |
| password field | `aria-label="Password input field"` + `<label>Password</label>` | `<label>Password</label>` + `data-testid="data-testid Password input field"` |
| submit button | `aria-label="Login button"` wrapping `<span>Log in</span>` | no aria-label, `data-testid="data-testid Login button"`, text `Log in` |
| **accessible name of submit** | **`Login button`** | **`Log in`** |

Stable across **all eight**: `input[name="user"]`, `input[name="password"]`, and exactly one
`button[type="submit"]`. The preamble selects on those, because handling a difference by picking
an attribute that does not differ beats branching on one that does.

That choice is not cosmetic. `aria-label` **wins over text content** when computing an
accessible name, so on 9.5.21 and 10.0.13 the submit button is named `Login button` and
`getByRole("button", { name: /log in/i })` matches **zero** elements. The pre-#60 code used
`getByLabel("Email or username").or(getByLabel("Username"))` plus a role-based submit — written,
per the issue, without a running instance.

Two further differences, neither affecting selection:

| Observation | Versions | Handling |
| --- | --- | --- |
| Landing URL is `/?orgId=1` | 9.5.21 → 11.0.0 | The preamble waits for "no longer on `/login`" and never asserts an exact post-login URL |
| Landing URL gains `&from=now-6h&to=now&timezone=browser` | 11.5.2 → 13.0.3 | same |
| First-run **Grafana Assistant dialog** covers the app on every boot | 13.0.3 only | Dismissed in the preamble. Nothing persists the dismissal (the testbed mounts no volume), and it is occlusion rather than hiding — Playwright reports elements underneath as visible, so nothing downstream would notice |
| **No change-password interstitial on any version** | all eight | Not clicked through. Compose sets `GF_SECURITY_ADMIN_PASSWORD`, so Grafana never forces the reset; the old conditional "Skip" click was dead code. If the screen ever appears the preamble raises a named failure instead of guessing |

### Failure is named, never silent

The preamble ends by asking `/api/user` — which answers 200 with the login on every matrix
version — and throws `LoginFailedError` carrying the stage that failed
(`open-login-page`, `fill-credentials`, `submit-login`, `password-change-interstitial`,
`verify-session`). The CLI exits **3** and writes no trajectory:

```text
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.
```

A DOM probe would have been weaker: a page can look logged in. This cannot be satisfied by
appearances, and a silent login failure can no longer masquerade as a step-1 locator failure in
the gate data.

**Scope note.** The `--fixture` path still records its login steps. That fixture is a
self-contained pipeline stand-in used by the integration and unit tests, not the gate task;
stripping its login would shrink it to two steps and weaken the seam test. The real task is
[#59](https://github.com/DevToolie/Paragent/issues/59)/[#24](https://github.com/DevToolie/Paragent/issues/24).

## What is recorded

For every step:
Login is **not** in this list — see the preamble section above. For every measured step:

1. **Action** — type, `param_refs`, optional `url_template` / `key` (never inline typed values).
2. **Locator candidates** (compiler preference order):
Expand Down Expand Up @@ -88,12 +160,15 @@ signals, not visibility claims.
| Path | Role |
| --- | --- |
| `src/recorder/session.ts` | `TrajectoryRecorder` |
| `src/recorder/preamble.ts` | `establishSession` — login scaffolding, records nothing |
| `src/recorder/locators.ts` | Candidate collection |
| `src/recorder/fingerprint.ts` | Pre/post fingerprints |
| `src/shared/landmarks.ts` | The one landmark enumeration, shared with `src/runner/page-state.ts` |
| `src/recorder/redact.ts` | Templatize / secret scan |
| `src/recorder/cli.ts` | Gate-task CLI |
| `src/recorder/fixtures/grafana-gate-login.html` | Deterministic stand-in |
| `src/recorder/fixtures/login-aria-label.html` | Login shape observed on 9.5.21 / 10.0.13 |
| `src/recorder/fixtures/login-labelled.html` | Login shape observed on 10.4.19 → 13.0.3 |
| `experiments/gate-v1/trajectories/*.json` | Committed example |

## How to run
Expand Down Expand Up @@ -127,6 +202,20 @@ Validate: `npm run validate:contracts` && `npm run test`
## Open questions / what I could not verify

- Final `site_key` / pinned Grafana version — blocked on ADR-0003 (B1).
- Live Grafana first-login password-change interstitial stability across versions.
- ~~Live Grafana first-login password-change interstitial stability across versions.~~
**Answered (#60)** — it does not appear on any of the eight, because compose sets
`GF_SECURITY_ADMIN_PASSWORD`. Unverified: what happens against a Grafana that does **not** set
it. The preamble fails with a named error there rather than guessing.
- **Replay has no preamble.** `establishSession` is recorder-side only. A live matrix run
([#62](https://github.com/DevToolie/Paragent/issues/62)) needs its own session establishment,
or every version will fail step 1 for lack of a session — and on 13.0.3 the first-run dialog
will occlude the app without Playwright noticing. That is scaffolding for the runner to
own; it must **not** become version-conditional logic inside replayed steps.
- The live trajectory currently has **one** measured step (`navigate /dashboards`) now that
login is a preamble. That is honest but far too thin to measure — exactly the gap
[#59](https://github.com/DevToolie/Paragent/issues/59) exists to close.
- Whether `input[name=...]` stays stable past 13.0.3. It held across five majors, but nothing
guarantees it; if it moves, the preamble fails loudly at `fill-credentials` rather than
silently recording a broken session.
- B5 allowlisted `data-testid` vocabulary — testids default `tenant_scoped: false` pending B5.
- Gate success metric — `[PENDING TRACK-1]`; not invented here.
40 changes: 27 additions & 13 deletions src/recorder/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
import { establishSession, LoginFailedError } from "./preamble.js";
import { RECORDER_VERSION, TrajectoryRecorder } from "./session.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -128,27 +129,31 @@ async function main() {
notes:
"Live capture against working-assumption Grafana OSS. Credentials from env only — never in artifact.",
},
// No `username` / `password` slots: since #60 the login is a preamble
// and no recorded step references those values, so declaring parameters
// for them would describe holes the trajectory does not have. The
// values were never written either way.
parameters: {
host: "string",
port: "integer",
username: "string",
password: "secret_ref",
},
bindings: { host, port },
});
await recorder.navigate("http://{host}:{port}/login", "Open the login page", ["host", "port"]);
await recorder.fill(
page.getByLabel("Email or username").or(page.getByLabel("Username")),
"username",
// Measured steps start AFTER this line. Login is scaffolding, not the
// measurement — see src/recorder/preamble.ts. Nothing above touches the
// recorder, so no preamble action reaches trajectory.steps or a
// step-validity denominator.
const session = await establishSession(page, {
baseUrl,
username,
"Fill username field",
password: userPass,
});
console.log(
`preamble: session established as ${session.user_login} ` +
`(landed ${session.landed_url}` +
`${session.dismissed_first_run_modal ? ", dismissed first-run dialog" : ""})`,
);
await recorder.fill(page.getByLabel("Password"), "password", userPass, "Fill username secret field");
await recorder.click(page.getByRole("button", { name: /log in/i }), "Submit login form");
const skipChange = page.getByRole("button", { name: /skip/i });
if (await skipChange.isVisible({ timeout: 3_000 }).catch(() => false)) {
await recorder.click(skipChange, "Skip first-login change prompt");
}

await recorder.navigate(
"http://{host}:{port}/dashboards",
"Open Dashboards list page",
Expand All @@ -164,6 +169,15 @@ async function main() {
}

main().catch((err) => {
// A login failure is a preamble failure, not a gate datum. Say so plainly so
// it is never mistaken for step-1 churn in the recorded task.
if (err instanceof LoginFailedError) {
console.error(`recorder: LOGIN FAILED (stage: ${err.stage}) — ${err.message}`);
console.error(
"No trajectory was written. This is scaffolding failing, not a measured step.",
);
process.exit(3);
}
console.error(err);
process.exit(1);
});
56 changes: 56 additions & 0 deletions src/recorder/fixtures/login-aria-label.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<!doctype html>
<!--
Grafana login surface as observed on 9.5.21 and 10.0.13 (2026-07-27, #60).

Identity comes from aria-label; there is NO <label> for the username field and
NO data-testid anywhere. This is the shape that a `getByLabel("Email or
username")` selector cannot find, which is why the preamble selects on `name`.

Reproduced from the live dump, not invented:
username: name=user, placeholder="email or username",
aria-label="Username input field", no id, no data-testid
password: name=password, id=current-password, placeholder="password",
aria-label="Password input field", <label>Password</label>
submit: <button type="submit" aria-label="Login button"><span>Log in</span></button>

The submit button's aria-label is load-bearing and MUST stay: aria-label wins
over text content for the accessible name, so this button is named "Login
button", not "Log in", and `getByRole("button", { name: /log in/i })` matches
nothing here. An earlier version of this fixture used a bare
<button type="submit">Log in</button> and let exactly that bug through to a
live run.

The "Show password" toggle is included because it is a second <button> on the
page — any submit selector has to not pick it.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Grafana</title>
</head>
<body>
<main>
<h1>Welcome to Grafana</h1>
<form id="login-form" method="post" action="/login-submit">
<input name="user" placeholder="email or username" aria-label="Username input field" />
<label for="current-password">Password</label>
<input
name="password"
id="current-password"
type="password"
placeholder="password"
aria-label="Password input field"
/>
<button
aria-label="Show password"
type="button"
aria-controls="current-password"
role="switch"
aria-checked="false"
></button>
<button type="submit" aria-label="Login button"><span>Log in</span></button>
</form>
<a href="/user/password/send-reset-email">Forgot your password?</a>
</main>
</body>
</html>
54 changes: 54 additions & 0 deletions src/recorder/fixtures/login-labelled.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<!doctype html>
<!--
Grafana login surface as observed on 10.4.19, 11.0.0, 11.5.2, 12.0.0, 12.2.1
and 13.0.3 (2026-07-27, #60).

Identity moved to a real <label> plus data-testid, and the aria-labels are
gone. This is the shape a `getByLabel("Username")` selector was written for on
the other side of the 10.4.19 boundary.

Reproduced from the live dump, not invented:
username: name=user, placeholder="email or username",
<label>Email or username</label>,
data-testid="data-testid Username input field", no aria-label
password: name=password, <label>Password</label>,
data-testid="data-testid Password input field"
submit: <button type="submit" data-testid="data-testid Login button">Log in</button>
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Grafana</title>
</head>
<body>
<main>
<h1>Welcome to Grafana</h1>
<form id="login-form" method="post" action="/login-submit">
<label for="user-input">Email or username</label>
<input
name="user"
id="user-input"
placeholder="email or username"
data-testid="data-testid Username input field"
/>
<label for="password-input">Password</label>
<input
name="password"
id="password-input"
type="password"
placeholder="password"
data-testid="data-testid Password input field"
/>
<button
aria-label="Show password"
type="button"
aria-controls="password-input"
role="switch"
aria-checked="false"
></button>
<button type="submit" data-testid="data-testid Login button">Log in</button>
</form>
<a href="/user/password/send-reset-email">Forgot your password?</a>
</main>
</body>
</html>
2 changes: 2 additions & 0 deletions src/recorder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*/
export { PACKAGE, RECORDER_VERSION, TrajectoryRecorder } from "./session.js";
export { captureFingerprint } from "./fingerprint.js";
export { establishSession, LoginFailedError } from "./preamble.js";
export type { EstablishSessionOptions, SessionInfo } from "./preamble.js";
export { collectLocatorCandidates } from "./locators.js";
export {
assertNoLiteralSecrets,
Expand Down
Loading
Loading