mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## 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 -->
172 lines
5.2 KiB
TypeScript
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('/')
|
|
}
|