Files
supabase/apps/studio/scripts/__tests__/ratchet-eslint-rules.test.ts
Ali Waseem 61078d2617 chore(studio): add jsx-a11y ESLint ratchet rules for statically-detectable a11y issues (#47582)
## Summary
- Follow-up to the axe-core accessibility audit (FE-3781), which found
1,733 failing elements across 126 Studio surfaces deduplicating to 12
root-cause families. A subset of those (missing accessible names/labels,
invalid/redundant ARIA, empty headings/anchors) is statically detectable
— this adds ESLint coverage for it instead of relying solely on the
runtime axe-core CI gate.
- Adds 13 `jsx-a11y` rules to `apps/studio/eslint.config.cjs` at
`'warn'`: `aria-props`, `aria-proptypes`, `role-supports-aria-props`,
`anchor-has-content`, `control-has-associated-label`
(`controlComponents: ['Button', 'Switch']`),
`label-has-associated-control` (`labelComponents: ['Label']`,
`controlComponents: ['Input', 'Switch']`), `aria-role`,
`no-redundant-roles`, `no-aria-hidden-on-focusable`,
`tabindex-no-positive`, `anchor-is-valid`, `heading-has-content`,
`no-distracting-elements`.
- Wires all 13 into the existing `lint:ratchet` script and initializes
their baselines in `apps/studio/.github/eslint-rule-baselines.json`, so
any *new* violation fails `studio-lint-ratchet.yml` while the
pre-existing ones (mostly `control-has-associated-label`: 274,
`label-has-associated-control`: 37) are tracked and shrink over time via
the weekly baseline-decrease cron.

Resolves
[FE-3795](https://linear.app/supabase/issue/FE-3795/add-jsx-a11y-eslint-ratchet-rules-for-statically-detectable-a11y).

## Test plan
- [x] `pnpm --filter studio run lint:ratchet` passes (exit 0, no
regressions)
- [x] Spot-checked several flagged instances against source to confirm
true positives (e.g. an unlabeled save/cancel icon-button pair in
`AIAssistantChatSelector.tsx`, an empty `<h3>` in `PITRForm.tsx`)
- [x] CI (`studio-lint-ratchet.yml`, typecheck.yml lint step) green on
this PR

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Expanded Studio’s accessibility linting to cover additional ARIA prop
validation, label/control relationships, anchor/heading validity,
role/ARIA correctness, and focus/tab behavior (including distracting
markup).
* Updated accessibility lint baselines so tracked violations remain
accurate as rules expand.
* **New Features**
* Enhanced the Studio lint “ratchet” workflow to load ratchet rule IDs
from an external `rules-file` instead of a long inline command.
* **Tests**
* Added an integration test to verify rule IDs are read from the
`rules-file`.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-03 20:33:12 +00:00

172 lines
5.2 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { runRatchet } from '../ratchet-eslint-rules'
const studioRoot = path.resolve(__dirname, '../..')
const repoRoot = path.resolve(studioRoot, '..', '..')
const scriptArgvPlaceholder = path.resolve(studioRoot, 'scripts', 'ratchet-eslint-rules.ts')
const tempDirs: string[] = []
afterEach(() => {
vi.restoreAllMocks()
while (tempDirs.length) {
const dir = tempDirs.pop()
if (dir) {
rmSync(dir, { recursive: true, force: true })
}
}
})
describe('ratchet-eslint-rules integration', () => {
it('captures per-file counts when initializing baselines', () => {
const tmp = createTempDir()
const metadataPath = path.join(tmp, 'baseline.json')
const eslintResults = buildEslintResults([
{ filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 1 } },
{ filePath: repoPath('apps/studio/src/b.ts'), rules: { 'no-console': 2 } },
])
const result = invokeRatchet(
['--metadata', metadataPath, '--rule', 'no-console', '--init'],
eslintResults
)
expect(result).toBe(0)
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'))
expect(metadata.rules['no-console']).toBe(3)
expect(metadata.ruleFiles['no-console']).toEqual({
[relativeToCwd('apps/studio/src/a.ts')]: 1,
[relativeToCwd('apps/studio/src/b.ts')]: 2,
})
})
it('reports offending files when regressions occur and metadata has per-file data', () => {
const tmp = createTempDir()
const metadataPath = path.join(tmp, 'baseline.json')
writeFileSync(
metadataPath,
JSON.stringify(
{
rules: { 'no-console': 2 },
ruleFiles: {
'no-console': {
[relativeToCwd('apps/studio/src/a.ts')]: 2,
},
},
},
null,
2
)
)
const eslintResults = buildEslintResults([
{ filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 3 } },
{ filePath: repoPath('apps/studio/src/b.ts'), rules: { 'no-console': 1 } },
])
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const result = invokeRatchet(
['--metadata', metadataPath, '--rule', 'no-console'],
eslintResults
)
expect(result).toBe(1)
const combinedErrors = errorSpy.mock.calls.map((args) => args.join(' ')).join('\n')
expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/a.ts')} (+1)`)
expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/b.ts')} (+1)`)
})
it('reads rule ids from --rules-file', () => {
const tmp = createTempDir()
const metadataPath = path.join(tmp, 'baseline.json')
const rulesFilePath = path.join(tmp, 'rules.json')
writeFileSync(rulesFilePath, JSON.stringify(['no-console', 'no-debugger']))
const eslintResults = buildEslintResults([
{ filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 1, 'no-debugger': 2 } },
])
const result = invokeRatchet(
['--metadata', metadataPath, '--rules-file', rulesFilePath, '--init'],
eslintResults
)
expect(result).toBe(0)
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'))
expect(metadata.rules['no-console']).toBe(1)
expect(metadata.rules['no-debugger']).toBe(2)
})
it('falls back gracefully when baseline is missing per-file data', () => {
const tmp = createTempDir()
const metadataPath = path.join(tmp, 'baseline.json')
writeFileSync(
metadataPath,
JSON.stringify(
{
rules: { 'no-console': 1 },
},
null,
2
)
)
const eslintResults = buildEslintResults([
{ filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 2 } },
])
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const result = invokeRatchet(
['--metadata', metadataPath, '--rule', 'no-console'],
eslintResults
)
expect(result).toBe(1)
const combinedErrors = errorSpy.mock.calls.map((args) => args.join(' ')).join('\n')
expect(combinedErrors).toContain('baseline missing file breakdown')
expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/a.ts')} (2 current)`)
})
})
function buildEslintResults(
files: Array<{ filePath: string; rules: Record<string, number> }>
): unknown[] {
return files.map(({ filePath, rules }) => ({
filePath,
messages: Object.entries(rules).flatMap(([ruleId, count]) =>
Array.from({ length: count }, () => ({ ruleId }))
),
}))
}
function createTempDir(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), 'ratchet-eslint'))
tempDirs.push(dir)
return dir
}
function repoPath(relPath: string): string {
return path.join(repoRoot, relPath)
}
function invokeRatchet(args: string[], eslintResults: unknown[]): number {
const argv = ['node', scriptArgvPlaceholder, ...args]
return runRatchet(argv, () => ({
results: eslintResults as any,
stderr: '',
}))
}
function relativeToCwd(relPath: string): string {
return path.relative(process.cwd(), repoPath(relPath)).split(path.sep).join('/')
}