Add e2e-selectors
Find interactive elements and key containers in the Grafana frontend that lack a stable test
selector, define a versioned selector in the @grafana/e2e-selectors package, and wire it
into the JSX as data-testid. This encodes the package's layout (pages vs components), its
semver versioning scheme, and its strict reuse / never-delete rules so selectors are added
correctly and don't break plugin end-to-end tests.
Resolve the target
Interpret the argument to decide scope:
- A file path → only that file.
- A list of files or described elements → each named target, one by one.
- A directory → all
.tsx files under it.
- A Grafana Pathfinder interactive guide — a guide directory or its
content.json (e.g.
under grafana-pathfinder-app/src/bundled-interactives/) → see "Pathfinder guide as target".
- "current file" / "open file" / no path but a file is open → the open file.
- No argument and no open file → ask for a target; never scan the whole frontend.
Pathfinder guide as target
Interactive guides drive Grafana's UI through CSS selectors (reftarget fields), so a weak
selector breaks a guide the same way it breaks a test. Process the guide, then fix the weak
targets at the source:
- Extract targets:
grep -o '"reftarget": *"[^"]*"' <guide>/content.json
- Skip navigation targets — blocks with
"action": "navigate" or URL-shaped values
(starting with / or http).
- Classify the rest. Strong (leave alone):
data-testid/data-cy-based selectors and
grafana: / {grafana:...} tokens. Weak (fix): text matching (:contains, :text),
aria-label / placeholder / title attributes, href, bare-id compounds, and positional
selectors (:nth-of-type, :nth-child, :nth-match).
- Locate the JSX in this repo rendering each weak target (search by id, testid fragment,
button text, aria-label). Some targets are not fixable here — external npm packages
(
@grafana/plugin-ui, @grafana/prometheus), Monaco editor internals, instance data
(dashboard titles, datasource names) — report those instead of forcing a change.
- Run Steps 1–6 below for the located elements. Updating the guide itself is out of scope —
include a weak-selector → new-selector mapping in your summary so the guide author can adopt
them.
Step 1 — Find the version key
All new selectors added in this run use a single version key: the current main version with
-pre and build tags stripped.
grep -m1 '"version"' package.json
13.2.0-pre → use '13.2.0'. On a release branch, read main's copy instead
(git show main:package.json | grep -m1 '"version"' — the local main ref can be stale, so
fetch first if in doubt). If you know the change will be backported, use the lowest release
version instead. Never hardcode — always compute it.
Step 2 — Identify elements
Target these in the file:
- Interactive controls a test would click or type into:
button, input, select,
textarea, a, links, toggles/switches, checkboxes, radios, menu items, tabs, and the
grafana-ui components that render them (Button, IconButton, Input, Select, Switch,
Checkbox, Tab, etc.).
- Key containers tests scope queries to: modals, panels, page sections, dialogs.
Skip any element that already has a data-testid or an existing selector — don't duplicate.
Exception — a static testid on a repeated item. A hardcoded literal inside a .map()
(every card rendering the same data-testid="data-source-card") identifies nothing: consumers
are forced into text matching or positional hacks to pick one item, so it's as weak as no
selector at all. Migrate it:
Define a parameterized selector keyed by a stable per-item value.
Preserve the legacy literal as the MIN_GRAFANA_VERSION entry so version-resolved consumers
keep working against older Grafana, keeping the signature compatible:
dataSourceCard: {
'13.2.0': (name: string) => `data-testid data source card ${name}`,
[MIN_GRAFANA_VERSION]: (_name: string) => 'data-source-card',
},
Update every in-repo usage of the old literal (jest and Playwright — grep public/ and
e2e-playwright/), and note in your summary that external code hardcoding the literal will
need the same one-line update.
Loop rule (important)
Inside a .map() / list render, do not put a selector on each inner interactive element —
that bloats the DOM and degrades render performance. Instead attach one parameterized
container selector to the repeated row/wrapper, keyed by a unique value. Tests then scope
their queries within the matched row.
Canonical example —
public/app/features/browse-dashboards/components/DashboardsTree.tsx:
<div
key={key}
{...rowProps}
data-testid={selectors.pages.BrowseDashboards.table.row(
'title' in dashboardItem ? dashboardItem.title : dashboardItem.uid
)}
>
{row.cells.map((cell) => /* inner cells get NO individual selector */)}
</div>
Step 3 — Reuse check (never duplicate)
Before defining anything, search the package for an existing selector covering this UI:
grep -rn "<keyword>" packages/grafana-e2e-selectors/src/selectors/components.ts packages/grafana-e2e-selectors/src/selectors/pages.ts
If you're modifying UI that already has a selector, reuse it — creating a new one breaks
plugin e2e tests. Only create a new selector for genuinely new UI.
Two special cases the grep can surface:
- Dormant entry — defined in the package but never wired into JSX (grep its value across
public/ and packages/): wire it instead of creating a parallel one, adding a
data-testid -prefixed version key first if the existing value is un-prefixed.
- Orphaned entry — the UI it tagged has been deleted: leave the entry in place (never
delete) and mention it in your summary.
Step 4 — Confirm the element accepts a selector prop
Before defining a selector for an element, confirm the target can actually receive it:
- Plain DOM elements (
div, button, input, a, …) always accept data-testid — proceed.
- grafana-ui / React components only accept it if the component is written to forward it.
Open the component and check that it either spreads remaining props onto the rendered DOM
(
{...rest} / {...otherProps} extending an HTMLAttributes type) or exposes a dedicated
prop for the test id (e.g. grafana-ui's Menu.Item uses a testId prop, not data-testid).
Use whichever the component actually supports. Known cases, to save a lookup (the source is
still authoritative if in doubt): Button, Input, FilterInput, and ToolbarButton
forward rest props, so plain data-testid works; Card spreads htmlProps onto its
container div; Menu.Item takes testId.
If the component accepts neither data-testid nor an equivalent prop, stop for that
element and surface it to the user — name the component, the file, and that it doesn't forward
a test id. Do not add a new prop, spread, or otherwise modify an existing component to make
it accept one. Move on to the remaining elements and report the skipped one in your summary.
Step 5 — Define the selector
- Where:
packages/grafana-e2e-selectors/src/selectors/components.ts if the element is
rendered on more than one route or ships in grafana-ui; pages.ts if it's tied to a single
route/screen (URLs also live there).
- Group: nest under an existing group that mirrors the UI hierarchy, or add a new group
named after the component/page. Place a new group next to related groups — the files are not
alphabetical.
- Shape: a versioned object whose key is the version from Step 1 and whose value is
prefixed
data-testid (the prefix tells the framework to match the data-testid
attribute rather than an aria-label):
MyComponent: {
submitButton: {
'13.2.0': 'data-testid MyComponent submit button',
},
},
Key naming. Keys form a public API plugins depend on, so name them deliberately:
- Casing: use PascalCase for a group key that names a distinct UI unit — a component,
form, modal, or drawer (
NewFolderForm, MoveModal, CreateNewButton); mirror the
component name. Use camelCase for conceptual/functional groups that aren't a single named
component (table, actions, emptyState) and for every leaf element key
(submitButton, searchInput, selectAllCheckbox).
- Name leaf keys by role, not by visible label —
moveButton, never move or Move. This
survives copy changes and reads unambiguously as a control.
- The markup drives the name — check the element itself, not its neighbours. The role
encoded in the key must match what the element actually renders. Use
…Button only for
something that renders/behaves as a button (a <button>, Button, or a MenuItem with
onClick and no url); use …Link for something that navigates (an <a>, LinkButton, or
MenuItem with url/href); use the matching role for inputs, checkboxes, etc. A suffix that
contradicts the markup — or one carried over from a differently-rendered element nearby — is
the defect. A single menu can legitimately contain both buttons and links, so their keys
should differ (newDashboardLink next to newFolderButton); that is correct, not an
inconsistency to flatten.
- Same markup ⇒ same name, everywhere in the run — not just within one group. Two controls
with the same markup/role are the same kind of thing and must be named identically wherever
they appear, across sibling keys and across groups. Two dropdown-trigger buttons must both be
triggerButton — not triggerButton in one group and button (or createNewButton) in
another; two action MenuItems must both be …Button — not moveButton next to bare
managePermissions. The usual cause of a violation is naming each group in isolation and
reaching for whatever reads locally; before finalizing, look across the whole file for other
instances of the same control and reuse that name.
- Key ↔ value agreement. The role word in the key must match the tail of its value: key
moveButton ⇒ value … move-button; key move ⇒ value … move. A mismatch (key says
moveButton, value ends …-move) is a reliable signal the key is wrong — reconcile them.
Prefer string selectors. Use a function selector only for genuinely parametric IDs
(loop/row keys, dashboard UIDs):
table: {
row: {
'13.2.0': (id: string) => `data-testid BrowseDashboards table row ${id}`,
},
},
Parameterize by a stable value (a uid, refId, or from/to token), never by display
text — translated or user-editable text reintroduces the i18n fragility the selector exists
to remove. Prefer a single parameter: some consumers (e.g. Pathfinder {grafana:path:param}
tokens) can only pass one. When adding a version key to a function selector, keep the
signature compatible across all keys (see the migration example in Step 2).
Upgrading legacy aria-label entries. A value without the data-testid prefix tells the
framework to match aria-label instead. Don't add aria-labels to JSX just to satisfy such an
entry — the upgrade path is to add a new prefixed version key to the existing entry and wire
data-testid in the JSX. Keep an aria-label only where it carries genuine accessibility value
(see "Aria-Labels vs data-testid" in contribute/style-guides/e2e-playwright.md).
Never edit or delete an existing entry. To change an existing selector's value, add a
new version key alongside the old one and keep the signature backwards compatible.
Ensure each new testid value is unique. The string value (the part after the data-testid
prefix) must not already exist anywhere in the package, or tests will match the wrong element.
Check before committing to a value:
grep -rn "MyComponent submit button" packages/grafana-e2e-selectors/src/selectors/
Expect zero matches other than the entry you just added. Also confirm the new values are
unique against each other within this run. If a value collides, pick a more specific one
(include the component/page name and the element's role).
Check for in-flight collisions. components.ts and pages.ts are hot files — someone may
be adding selectors for the same UI right now. Before finalizing group names and values, scan
open PRs touching them: gh pr list --search "e2e-selectors" --state open. Always avoid group
names and values those PRs introduce. What to do about overlapping UI depends on why you're
touching it:
- The element was explicitly requested (named in the task, or a weak guide target you were
asked to fix): do the work anyway and surface the overlap in your summary — an open draft PR
is not a reason to silently return nothing the user asked for. If the PR's approach conflicts
with this skill's rules (e.g. it satisfies a legacy entry with an aria-label), say so; the
user decides which lands.
- You found the element during open-ended discovery (directory sweep, guide scan choosing
among many candidates): skip UI an open PR already covers and spend the effort on uncovered
targets instead.
Step 6 — Apply in JSX
Ensure the import exists (add it if missing):
import { selectors } from '@grafana/e2e-selectors';
Then wire the attribute:
// static control
<Button data-testid={selectors.components.MyComponent.submitButton}
Save
</Button>
// parameterized loop-row container
<div data-testid={selectors.pages.BrowseDashboards.table.row(item.uid)} />
Many grafana-ui components forward a data-testid prop, so passing it directly works; for
plain DOM elements set the attribute literally.
Examples
// Static selector on a control — Drawer close button
<IconButton data-testid={selectors.components.Drawer.General.close} />
// Parameterized selector for a repeated item — Tab title
<button data-testid={selectors.components.Tab.title(label)}>{label}</button>
// Page-level container selector
<div data-testid={selectors.pages.Explore.General.container}>{children}</div>
Rules checklist
- Never delete a selector — external plugins depend on them.
- Reuse the existing selector when touching UI that already has one; only create for new UI.
- Confirm the element accepts
data-testid or an equivalent prop before defining a selector; if
it doesn't, surface it to the user and never add a prop to an existing component.
- Every new testid value must be unique across the package and within this run.
- Prefer string selectors; function selectors only for parametric loop/row/UID values.
- Name a key's role to match the element's actual markup (
…Button for buttons, …Link for
navigation, etc.) — never carry it over from a differently-rendered neighbour; keep the key's
role word in sync with its value.
- Same markup ⇒ same name everywhere in the run — name equivalent controls identically across
sibling keys and across groups (e.g. every dropdown trigger is
triggerButton); scan the whole
file for existing instances before naming a new one.
- New selectors use the version key from Step 1 (no
-pre/build tags).
- To change a value, add a new version key — don't edit the old value or change the signature.
- In loops, selector goes on the row/container, not each inner element.
- A static testid on a repeated item is as weak as none — migrate it to a parameterized
selector, preserving the legacy literal as the
MIN_GRAFANA_VERSION entry and updating every
in-repo usage of the old literal.
- Wire dormant (defined-but-never-used) entries instead of creating parallel ones; report
orphaned entries, never delete them.
- Function-selector parameters are stable values (uid, refId, tokens), never display text;
prefer a single parameter.
See packages/grafana-e2e-selectors/src/selectors/README.md and
contribute/style-guides/e2e-playwright.md for the authoritative guidance.
Verify
yarn typecheck — confirms the selector path and any function signature resolve. This runs
the whole monorepo and takes several minutes; that's expected.
- If a literal testid was replaced (Step 2 exception), grep the old literal across
public/
and e2e-playwright/ — expect zero remaining references.
- Selectors are resolved at runtime by the package's resolver; no codegen step is needed
after editing
components.ts / pages.ts.
yarn lint the changed files — the import/order rule cares where the new
@grafana/e2e-selectors import lands (alphabetical within the @grafana/* group).
1---2name: add-e2e-selectors3description: Add reliable @grafana/e2e-selectors to interactive elements and key containers in the Grafana frontend. Use when adding e2e selectors, data-testid attributes, or test selectors to React components, when a file or component lacks selectors for testing, or when asked to make elements testable. Also use when given a Grafana Pathfinder interactive guide (a guide directory or content.json) to audit or fix — it extracts the guide's reftarget selectors, identifies weak ones, and fixes them at the source in Grafana's JSX. Defines versioned selectors in the e2e-selectors package and wires data-testid into JSX. Accepts a file path, a list of targets, a directory, a pathfinder guide, or the current/open file.4---5
6# Add e2e-selectors
7
8Find interactive elements and key containers in the Grafana frontend that lack a stable test
9selector, define a **versioned** selector in the `@grafana/e2e-selectors` package, and wire it
10into the JSX as `data-testid`. This encodes the package's layout (`pages` vs `components`), its
11semver versioning scheme, and its strict reuse / never-delete rules so selectors are added
12correctly and don't break plugin end-to-end tests.
13
14## Resolve the target
15
16Interpret the argument to decide scope:
17
18- **A file path** → only that file.
19- **A list of files or described elements** → each named target, one by one.
20- **A directory** → all `.tsx` files under it.
21- **A Grafana Pathfinder interactive guide** — a guide directory or its `content.json` (e.g.
22 under `grafana-pathfinder-app/src/bundled-interactives/`) → see "Pathfinder guide as target".
23- **"current file" / "open file" / no path but a file is open** → the open file.
24- **No argument and no open file** → ask for a target; never scan the whole frontend.
25
26### Pathfinder guide as target
27
28Interactive guides drive Grafana's UI through CSS selectors (`reftarget` fields), so a weak
29selector breaks a guide the same way it breaks a test. Process the guide, then fix the weak
30targets at the source:
31
321. Extract targets: `grep -o '"reftarget": *"[^"]*"' <guide>/content.json`
332. Skip navigation targets — blocks with `"action": "navigate"` or URL-shaped values
34 (starting with `/` or `http`).
353. Classify the rest. **Strong** (leave alone): `data-testid`/`data-cy`-based selectors and
36 `grafana:` / `{grafana:...}` tokens. **Weak** (fix): text matching (`:contains`, `:text`),
37 `aria-label` / `placeholder` / `title` attributes, `href`, bare-id compounds, and positional
38 selectors (`:nth-of-type`, `:nth-child`, `:nth-match`).
394. Locate the JSX in this repo rendering each weak target (search by id, testid fragment,
40 button text, aria-label). Some targets are not fixable here — external npm packages
41 (`@grafana/plugin-ui`, `@grafana/prometheus`), Monaco editor internals, instance data
42 (dashboard titles, datasource names) — report those instead of forcing a change.
435. Run Steps 1–6 below for the located elements. Updating the guide itself is out of scope —
44 include a weak-selector → new-selector mapping in your summary so the guide author can adopt
45 them.
46
47## Step 1 — Find the version key
48
49All new selectors added in this run use a single version key: the current `main` version with
50`-pre` and build tags stripped.
51
52```bash
53grep -m1 '"version"' package.json
54```
55
56`13.2.0-pre` → use `'13.2.0'`. On a release branch, read main's copy instead
57(`git show main:package.json | grep -m1 '"version"'` — the local `main` ref can be stale, so
58fetch first if in doubt). If you know the change will be backported, use the lowest release
59version instead. Never hardcode — always compute it.
60
61## Step 2 — Identify elements
62
63Target these in the file:
64
65- **Interactive controls** a test would click or type into: `button`, `input`, `select`,
66 `textarea`, `a`, links, toggles/switches, checkboxes, radios, menu items, tabs, and the
67 grafana-ui components that render them (`Button`, `IconButton`, `Input`, `Select`, `Switch`,
68 `Checkbox`, `Tab`, etc.).
69- **Key containers** tests scope queries to: modals, panels, page sections, dialogs.
70
71Skip any element that already has a `data-testid` or an existing selector — don't duplicate.
72
73**Exception — a static testid on a repeated item.** A hardcoded literal inside a `.map()`
74(every card rendering the same `data-testid="data-source-card"`) identifies nothing: consumers
75are forced into text matching or positional hacks to pick one item, so it's as weak as no
76selector at all. Migrate it:
77
781. Define a parameterized selector keyed by a stable per-item value.
792. Preserve the legacy literal as the `MIN_GRAFANA_VERSION` entry so version-resolved consumers
80 keep working against older Grafana, keeping the signature compatible:
81
82 ```typescript
83 dataSourceCard: {
84 '13.2.0': (name: string) => `data-testid data source card ${name}`,
85 [MIN_GRAFANA_VERSION]: (_name: string) => 'data-source-card',
86 },
87 ```
88
893. Update every in-repo usage of the old literal (jest and Playwright — grep `public/` and
90 `e2e-playwright/`), and note in your summary that external code hardcoding the literal will
91 need the same one-line update.
92
93### Loop rule (important)
94
95Inside a `.map()` / list render, **do not** put a selector on each inner interactive element —
96that bloats the DOM and degrades render performance. Instead attach **one parameterized
97container selector to the repeated row/wrapper**, keyed by a unique value. Tests then scope
98their queries within the matched row.
99
100Canonical example —
101`public/app/features/browse-dashboards/components/DashboardsTree.tsx`:
102
103```tsx
104<div
105 key={key}
106 {...rowProps}
107 data-testid={selectors.pages.BrowseDashboards.table.row(
108 'title' in dashboardItem ? dashboardItem.title : dashboardItem.uid
109 )}
110>
111 {row.cells.map((cell) => /* inner cells get NO individual selector */)}
112</div>
113```
114
115## Step 3 — Reuse check (never duplicate)
116
117Before defining anything, search the package for an existing selector covering this UI:
118
119```bash
120grep -rn "<keyword>" packages/grafana-e2e-selectors/src/selectors/components.ts packages/grafana-e2e-selectors/src/selectors/pages.ts
121```
122
123If you're modifying UI that **already has** a selector, reuse it — creating a new one breaks
124plugin e2e tests. Only create a new selector for genuinely new UI.
125
126Two special cases the grep can surface:
127
128- **Dormant entry** — defined in the package but never wired into JSX (grep its value across
129 `public/` and `packages/`): wire it instead of creating a parallel one, adding a
130 `data-testid `-prefixed version key first if the existing value is un-prefixed.
131- **Orphaned entry** — the UI it tagged has been deleted: leave the entry in place (never
132 delete) and mention it in your summary.
133
134## Step 4 — Confirm the element accepts a selector prop
135
136Before defining a selector for an element, confirm the target can actually receive it:
137
138- **Plain DOM elements** (`div`, `button`, `input`, `a`, …) always accept `data-testid` — proceed.
139- **grafana-ui / React components** only accept it if the component is written to forward it.
140 Open the component and check that it either spreads remaining props onto the rendered DOM
141 (`{...rest}` / `{...otherProps}` extending an `HTMLAttributes` type) **or** exposes a dedicated
142 prop for the test id (e.g. grafana-ui's `Menu.Item` uses a `testId` prop, not `data-testid`).
143 Use whichever the component actually supports. Known cases, to save a lookup (the source is
144 still authoritative if in doubt): `Button`, `Input`, `FilterInput`, and `ToolbarButton`
145 forward rest props, so plain `data-testid` works; `Card` spreads `htmlProps` onto its
146 container div; `Menu.Item` takes `testId`.
147
148If the component accepts **neither** `data-testid` nor an equivalent prop, **stop for that
149element** and surface it to the user — name the component, the file, and that it doesn't forward
150a test id. **Do not** add a new prop, spread, or otherwise modify an existing component to make
151it accept one. Move on to the remaining elements and report the skipped one in your summary.
152
153## Step 5 — Define the selector
154
155- **Where:** `packages/grafana-e2e-selectors/src/selectors/components.ts` if the element is
156 rendered on more than one route or ships in grafana-ui; `pages.ts` if it's tied to a single
157 route/screen (URLs also live there).
158- **Group:** nest under an existing group that mirrors the UI hierarchy, or add a new group
159 named after the component/page. Place a new group next to related groups — the files are not
160 alphabetical.
161- **Shape:** a versioned object whose key is the version from Step 1 and whose value is
162 prefixed `data-testid ` (the prefix tells the framework to match the `data-testid`
163 attribute rather than an aria-label):
164
165```typescript
166MyComponent: {
167 submitButton: {
168 '13.2.0': 'data-testid MyComponent submit button',
169 },
170},
171```
172
173- **Key naming.** Keys form a public API plugins depend on, so name them deliberately:
174 - **Casing:** use **PascalCase** for a group key that names a distinct UI unit — a component,
175 form, modal, or drawer (`NewFolderForm`, `MoveModal`, `CreateNewButton`); mirror the
176 component name. Use **camelCase** for conceptual/functional groups that aren't a single named
177 component (`table`, `actions`, `emptyState`) **and for every leaf element key**
178 (`submitButton`, `searchInput`, `selectAllCheckbox`).
179 - **Name leaf keys by role, not by visible label** — `moveButton`, never `move` or `Move`. This
180 survives copy changes and reads unambiguously as a control.
181 - **The markup drives the name — check the element itself, not its neighbours.** The role
182 encoded in the key must match what the element actually renders. Use `…Button` only for
183 something that renders/behaves as a button (a `<button>`, `Button`, or a `MenuItem` with
184 `onClick` and no `url`); use `…Link` for something that navigates (an `<a>`, `LinkButton`, or
185 `MenuItem` with `url`/`href`); use the matching role for inputs, checkboxes, etc. A suffix that
186 contradicts the markup — or one carried over from a differently-rendered element nearby — is
187 the defect. A single menu can legitimately contain both buttons and links, so their keys
188 _should_ differ (`newDashboardLink` next to `newFolderButton`); that is correct, not an
189 inconsistency to flatten.
190 - **Same markup ⇒ same name, everywhere in the run — not just within one group.** Two controls
191 with the same markup/role are the same kind of thing and must be named identically wherever
192 they appear, across sibling keys _and_ across groups. Two dropdown-trigger buttons must both be
193 `triggerButton` — not `triggerButton` in one group and `button` (or `createNewButton`) in
194 another; two action `MenuItem`s must both be `…Button` — not `moveButton` next to bare
195 `managePermissions`. The usual cause of a violation is naming each group in isolation and
196 reaching for whatever reads locally; before finalizing, look across the whole file for other
197 instances of the same control and reuse that name.
198 - **Key ↔ value agreement.** The role word in the key must match the tail of its value: key
199 `moveButton` ⇒ value `… move-button`; key `move` ⇒ value `… move`. A mismatch (key says
200 `moveButton`, value ends `…-move`) is a reliable signal the key is wrong — reconcile them.
201
202- **Prefer string selectors.** Use a **function selector only for genuinely parametric IDs**
203 (loop/row keys, dashboard UIDs):
204
205```typescript
206table: {
207 row: {
208 '13.2.0': (id: string) => `data-testid BrowseDashboards table row ${id}`,
209 },
210},
211```
212
213Parameterize by a **stable value** (a uid, refId, or `from`/`to` token), never by display
214text — translated or user-editable text reintroduces the i18n fragility the selector exists
215to remove. Prefer a single parameter: some consumers (e.g. Pathfinder `{grafana:path:param}`
216tokens) can only pass one. When adding a version key to a function selector, keep the
217signature compatible across all keys (see the migration example in Step 2).
218
219- **Upgrading legacy aria-label entries.** A value without the `data-testid ` prefix tells the
220 framework to match `aria-label` instead. Don't add aria-labels to JSX just to satisfy such an
221 entry — the upgrade path is to add a new prefixed version key to the _existing_ entry and wire
222 `data-testid` in the JSX. Keep an aria-label only where it carries genuine accessibility value
223 (see "Aria-Labels vs data-testid" in `contribute/style-guides/e2e-playwright.md`).
224
225- **Never edit or delete an existing entry.** To change an existing selector's value, add a
226 **new version key** alongside the old one and keep the signature backwards compatible.
227
228- **Ensure each new testid value is unique.** The string value (the part after the `data-testid `
229 prefix) must not already exist anywhere in the package, or tests will match the wrong element.
230 Check before committing to a value:
231
232 ```bash
233 grep -rn "MyComponent submit button" packages/grafana-e2e-selectors/src/selectors/
234 ```
235
236 Expect **zero** matches other than the entry you just added. Also confirm the new values are
237 unique against each other within this run. If a value collides, pick a more specific one
238 (include the component/page name and the element's role).
239
240- **Check for in-flight collisions.** `components.ts` and `pages.ts` are hot files — someone may
241 be adding selectors for the same UI right now. Before finalizing group names and values, scan
242 open PRs touching them: `gh pr list --search "e2e-selectors" --state open`. Always avoid group
243 names and values those PRs introduce. What to do about overlapping _UI_ depends on why you're
244 touching it:
245 - **The element was explicitly requested** (named in the task, or a weak guide target you were
246 asked to fix): do the work anyway and surface the overlap in your summary — an open draft PR
247 is not a reason to silently return nothing the user asked for. If the PR's approach conflicts
248 with this skill's rules (e.g. it satisfies a legacy entry with an aria-label), say so; the
249 user decides which lands.
250 - **You found the element during open-ended discovery** (directory sweep, guide scan choosing
251 among many candidates): skip UI an open PR already covers and spend the effort on uncovered
252 targets instead.
253
254## Step 6 — Apply in JSX
255
256Ensure the import exists (add it if missing):
257
258```tsx
259import { selectors } from '@grafana/e2e-selectors';
260```
261
262Then wire the attribute:
263
264```tsx
265// static control
266<Button data-testid={selectors.components.MyComponent.submitButton} onClick={onSubmit}>
267 Save
268</Button>
269
270// parameterized loop-row container
271<div data-testid={selectors.pages.BrowseDashboards.table.row(item.uid)} />
272```
273
274Many grafana-ui components forward a `data-testid` prop, so passing it directly works; for
275plain DOM elements set the attribute literally.
276
277## Examples
278
279```tsx
280// Static selector on a control — Drawer close button
281<IconButton data-testid={selectors.components.Drawer.General.close} onClick={onClose} />
282
283// Parameterized selector for a repeated item — Tab title
284<button data-testid={selectors.components.Tab.title(label)}>{label}</button>
285
286// Page-level container selector
287<div data-testid={selectors.pages.Explore.General.container}>{children}</div>
288```
289
290## Rules checklist
291
292- Never delete a selector — external plugins depend on them.
293- Reuse the existing selector when touching UI that already has one; only create for new UI.
294- Confirm the element accepts `data-testid` or an equivalent prop before defining a selector; if
295 it doesn't, surface it to the user and never add a prop to an existing component.
296- Every new testid value must be unique across the package and within this run.
297- Prefer string selectors; function selectors only for parametric loop/row/UID values.
298- Name a key's role to match the element's actual markup (`…Button` for buttons, `…Link` for
299 navigation, etc.) — never carry it over from a differently-rendered neighbour; keep the key's
300 role word in sync with its value.
301- Same markup ⇒ same name everywhere in the run — name equivalent controls identically across
302 sibling keys and across groups (e.g. every dropdown trigger is `triggerButton`); scan the whole
303 file for existing instances before naming a new one.
304- New selectors use the version key from Step 1 (no `-pre`/build tags).
305- To change a value, add a new version key — don't edit the old value or change the signature.
306- In loops, selector goes on the row/container, not each inner element.
307- A static testid on a repeated item is as weak as none — migrate it to a parameterized
308 selector, preserving the legacy literal as the `MIN_GRAFANA_VERSION` entry and updating every
309 in-repo usage of the old literal.
310- Wire dormant (defined-but-never-used) entries instead of creating parallel ones; report
311 orphaned entries, never delete them.
312- Function-selector parameters are stable values (uid, refId, tokens), never display text;
313 prefer a single parameter.
314
315See `packages/grafana-e2e-selectors/src/selectors/README.md` and
316`contribute/style-guides/e2e-playwright.md` for the authoritative guidance.
317
318## Verify
319
320- `yarn typecheck` — confirms the selector path and any function signature resolve. This runs
321 the whole monorepo and takes several minutes; that's expected.
322- If a literal testid was replaced (Step 2 exception), grep the old literal across `public/`
323 and `e2e-playwright/` — expect zero remaining references.
324- Selectors are resolved at runtime by the package's resolver; **no codegen step** is needed
325 after editing `components.ts` / `pages.ts`.
326- `yarn lint` the changed files — the `import/order` rule cares where the new
327 `@grafana/e2e-selectors` import lands (alphabetical within the `@grafana/*` group).