Files
3x-ui/frontend/src/test/antd-a11y-guard.test.ts
T
Alireza Ahmadi 1bdb517b88 fix(a11y): silence live stats and make Select tags removable for screen readers
Ant Design's Spin wraps its content in aria-live="polite", so every
WebSocket tick on the dashboard, inbounds, clients and nodes pages was
announced, and only the changed number was read, with no label.
Pass aria-live="off" on every Spin that wraps content; aria-busy still
signals loading. Each vital tile is now a group named by its metric,
so "47.2 %" is read as CPU.

rc-select renders each tag's remove "x" as an aria-hidden span with no
tabIndex, so screen-reader users could only Backspace the last value
away, never remove one SNI or inbound from the middle.
selectTagRender swaps in a real button named "Remove <value>" and
moves focus to the next tag after removal. It is applied to every
tags/multiple Select. maxTagCount="responsive" is dropped because it
hid selected inbounds behind "+N", where no screen reader could reach
them.

Two oxlint rules (antd-a11y/spin-content-not-live and
antd-a11y/select-tag-remove) keep new code from regressing either fix.
2026-11-01 16:49:19 +01:00

75 lines
2.5 KiB
TypeScript

import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
// The Spin/Select screen-reader guards walk oxlint's AST by hand, so an oxlint
// bump can kill them silently. These tests fail loudly when it does.
const FIXTURES = 'tools/oxlint/__fixtures__';
const SPIN_RULE = 'antd-a11y(spin-content-not-live)';
const SELECT_RULE = 'antd-a11y(select-tag-remove)';
function runGuard(target: string): string {
try {
execFileSync(
process.execPath,
[
'node_modules/oxlint/bin/oxlint',
'-c',
`${FIXTURES}/antd-a11y.oxlintrc.json`,
'-f',
'unix',
target,
],
{ encoding: 'utf8', stdio: 'pipe' },
);
return '';
} catch (error) {
const { status, stdout } = error as { status?: number | null; stdout?: string };
if (typeof status !== 'number') throw error;
return String(stdout ?? '');
}
}
function hitLines(output: string, rule: string): number[] {
return output
.split('\n')
.filter((line) => line.includes(rule))
.map((line) => Number(line.split(':')[1]));
}
describe('antd-a11y-guard oxlint plugin', () => {
it('flags a Spin whose content would be announced on every update', () => {
expect(hitLines(runGuard(`${FIXTURES}/antd-a11y-flagged.tsx`), SPIN_RULE)).toEqual([5]);
});
it('flags tag Selects without a focusable remove button or with hidden tags', () => {
expect(hitLines(runGuard(`${FIXTURES}/antd-a11y-flagged.tsx`), SELECT_RULE)).toEqual([6, 7]);
});
it('accepts quiet Spins, bare indicators, single Selects, tagRender and spreads', () => {
const output = runGuard(`${FIXTURES}/antd-a11y-ok.tsx`);
expect(output).not.toContain(SPIN_RULE);
expect(output).not.toContain(SELECT_RULE);
});
});
describe('antd-a11y-guard wiring', () => {
const config = JSON.parse(readFileSync('.oxlintrc.json', 'utf8')) as {
jsPlugins: string[];
overrides: { files: string[]; rules: Record<string, unknown> }[];
};
it('enforces both rules on app sources but not on stories or tests', () => {
expect(config.jsPlugins).toContain('./tools/oxlint/antd-a11y-guard.mjs');
for (const rule of ['antd-a11y/spin-content-not-live', 'antd-a11y/select-tag-remove']) {
expect(config.overrides.find((o) => o.rules[rule] === 'error')?.files).toEqual([
'src/**/*.tsx',
]);
expect(config.overrides.find((o) => o.rules[rule] === 'off')?.files).toEqual([
'src/**/*.stories.tsx',
'src/test/**',
]);
}
});
});