testid-a11y-mining
Read-only codemod that mines patterns around data-testid and Testing Library queries—it does not rewrite files in Step 1. Findings go to the testid_mining metric via useMetricAtom, and the same rows (with exact source spans) are appended to workflow state testid_a11y_findings via setState from codemod:workflow so Steps 2–3 can consume them as ${{ state.testid_a11y_findings }} in workflow.yaml.
Background: Test IDs are an a11y smell (Dominik Dorfmeister). The tool encodes that role-based queries align with how users experience the app; getByTestId does not exercise accessible names/roles, and test IDs on clickable non-semantic elements can mask regressions (e.g. <div onClick … data-testid>).
Three-step workflow
| Step | What | Details |
|---|---|---|
| 1 — Mine | Deterministic JSSG scan | Broad detection, no false negatives on those rules; false positives OK. Each emission includes line, snippet, validationGuide, fixGuide, start/end line & column, locationRef, fileUri, and a state array for agents. |
| 2 — Validate | AI step in workflow.yaml | Full validation checklist and output contract are inlined in that file’s Step 2 ai block. |
| 3 — Fix | AI step in workflow.yaml | Full fix playbook is inlined in that file’s Step 3 ai block. |
Operational detail for Steps 2–3 lives only in workflow.yaml (no separate docs folder).
Applicability
Broadly applicable to frontends that:
- Use React (TSX) and @testing-library/react-style APIs (screen.getByTestId, etc.)
- Use data-testid / data-test-id on JSX
Caveats (any repo)
- Test file detection uses filename patterns (e.g. *.spec.tsx, *.test.tsx, and the fixture path tests/.../input.tsx), not project-specific rules.
- “Design system path” (locationCategory) is a path heuristic (a components path segment). It may or may not match how your repo organizes design-system or shared UI code.
- Static analysis only — there is no runtime a11y (no axe, no browser). Step 2 is where you add that judgment.
What it detects
| Kind | Severity (default) | Summary |
|---|---|---|
| clickable_nonsemantic_with_testid | high | div / span with onClick (or pointer/key handlers) and data-testid |
| data_testid_on_semantic_tag | medium | data-testid on button, a, input, textarea, select |
| composite_widget_spec_review | medium | Spec file whose path suggests composite UI (Select, Menu, …) and uses getByTestId |
| get_by_testid_role_hint | medium | In spec-like files: getByTestId / queryByTestId / … with a string literal that looks “role-ish” (heuristic tiers A/B) |
| many_testids_in_ds_file | low | Under a path with a components segment: ≥ 5 distinct static data-testid values in one file |
Replacement opportunity (component-side): likely_getByRole vs manual_review (icon-only, dynamic/i18n text, form controls, etc.).
Workflow state
After Step 1, state.testid_a11y_findings is a JSON array of objects: kind, file, line, snippet, validationGuide, fixGuide, dims (string dimensions from the miner), and location (startLine, startColumn, endLine, endColumn, fileUri, locationRef). Spans use 1-based lines and columns (editor-style). fileUri follows file:// (relative paths are preserved as scanned). Steps 2–3 prompts in workflow.yaml embed this state for coding agents.
Metric dimensions
Each emission includes string dimensions where applicable:
- kind — finding type (see table above)
- severity — high | medium | low (triage priority, not proof of user impact)
- confidence — how reliable the matcher is (high | medium | low)
- replacementOpportunity — likely_getByRole | manual_review (component hits)
- file — file path of the scanned source
- line — 1-based start line of the anchor span (or 0 for file-level-only rows)
- startLine, startColumn, endLine, endColumn — 1-based inclusive span of the anchor node (columns match typical editor “Go to Line/Column”)
- locationRef — path:line:column at the anchor start (grep / terminal friendly)
- fileUri — file:// URL for the file (fragment-less; pair with line/column when opening)
- snippet — truncated one-line source at the anchor (or (file-level))
- validationGuide — short Step 2 hint (full checklist is in workflow.yaml Step 2 ai.prompt)
- fixGuide — short Step 3 hint (full playbook is in workflow.yaml Step 3 ai.prompt)
- locationCategory — spec | design_system_path | app_component
- interactionType — how the queried id is used in tests when inferable: read (query only), click, type, focus, assertion (inside expect(...)); component-side rules use click for non-semantic click targets and read for static markup signals; file-level findings use read
- userEditable — true | false | unknown: semantic tags (input / textarea / select → true; button / a → false; contentEditable → true); test-id queries use a name heuristic; unknown when not classified
- targetCategoryMask — one cardinality string encoding four target questions (each position is Y = yes, N = no, ? = unknown): **C**ontainer · user-Editable field · Interactive control · implementation Hook (e.g. YNYN, ???? for file-level findings). Heuristics use tag/attributes, role, handlers, and test-id naming patterns.
- tier — tier A/B for get_by_testid_role_hint only (role-ish id heuristic)
Additional dimensions when applicable:
- queriedTestId — literal string for get_by_testid_role_hint
- testIdValue — static data-testid / data-test-id value (or [dynamic])
- htmlTag — intrinsic tag for JSX-based findings
- exampleQueriedTestId — literal for composite_widget_spec_review when available
- distinctTestIdCount — for many_testids_in_ds_file
Heuristic test-id names are hints, not proof that getByRole will work. data-testid on semantic elements is often intentional (i18n, flaky names). Density is a weak signal. See Applicability → Caveats above.