Przejdź do głównej zawartości

Testowanie dostępności z agentami AI łączy deterministyczny skaner (axe-core uruchamiany w Playwright), testy klawiatury i fokusu pisane na rolach i nazwach dostępnych oraz krótki ręczny test z czytnikiem ekranu. Agent pisze testy i poprawki, a zapisany w repozytorium baseline, który zmienia się tylko za zgodą właściciela kodu, blokuje w CI nowe krytyczne i poważne naruszenia WCAG 2.2 A lub AA.

Ta strona jest dla programistów i tech leadów, którzy wdrażają UI pisane dziś przez agentów. Twoja firma właśnie dostała pismo od prawnika w sprawie formularza rejestracji, a audyt znajduje pola bez etykiet, menu, z którego nie da się wyjść klawiaturą, i przyciski z kontrastem poniżej AA. Agent poprawił oczywiste błędy w jedno popołudnie. Teraz potrzebujesz dowodu, że kolejne sto pull requestów od agentów ich nie przywróci.

  • Bramkę Playwright + @axe-core/playwright, która blokuje CI przy nowych naruszeniach WCAG 2.2 A/AA o wadze critical lub serious, z baseline’em dla tych, które już masz.
  • Testy klawiatury i fokusu dla dialogów, menu i formularzy, które padają, gdy znika nazwa dostępna.
  • Konfigurację MCP przeglądarki dla Claude Code, Codex i Cursora, dzięki której agent audytuje stany, do których skaner nigdy nie dociera.
  • Cztery prompty do skopiowania: postawienie bramki, spłacanie baseline’u, testy klawiatury i przygotowanie ręcznego testu z czytnikiem ekranu.
  • Podział odpowiedzialności: które kontrole są automatyczne, które ocenia model, a które podpisuje człowiek.

Zanim poprosisz agenta o cokolwiek, podziel WCAG na to, o czym rozstrzyga maszyna, i to, o czym rozstrzyga człowiek. Ten podział wyznacza każdą bramkę na tej stronie.

KontrolaKto rozstrzygaBramka
Brak etykiet, nazw, alt, błędne ARIA, kontrastreguły axe-core (deterministyczne)CI, blokująca
Kolejność tabulacji, pułapka fokusu, powrót fokusu, Escapetesty klawiatury w PlaywrightCI, blokująca
Antywzorce w JSX, zanim strona się wyrenderujeeslint-plugin-jsx-a11y lub reguły jsx-a11y w oxlintlint, blokująca
Czy tekst alt coś znaczy? Czy komunikat błędu jest jasny?sędzia z rubrykąCI, doradcza (kontrole oceniane przez model)
Czy ścieżka ma sens z czytnikiem ekranu?człowiek z VoiceOver lub NVDApodpis przy wydaniu, na próbce

axe-core zgłasza to, o czym potrafi rozstrzygnąć. Strona bez naruszeń może nadal ogłaszać „obraz” przy każdym zdjęciu produktu albo czytać koszyk w kolejności, której nikt by nie wybrał. Czysty skan oznacza „brak znanych błędów mechanicznych”, nigdy „dostępne”.

Bramka uruchamia axe-core na każdej krytycznej ścieżce, porównuje wynik z baseline’em zapisanym w repozytorium i blokuje tylko naruszenia, których w baseline’ie nie ma. Dzięki temu włączysz ją już dziś, w kodzie z setkami istniejących naruszeń, i będziesz je spłacać stopniowo.

  1. Zainstaluj skaner obok Playwright (@axe-core/playwright 4.13.0 i @playwright/test 1.63.0 na dzień 2026-09-26):

    Okno terminala
    npm install -D @playwright/test @axe-core/playwright
  2. Dodaj plik testu scan.spec.ts. Test skanu filtruje po tagach WCAG A/AA do wersji 2.2 i tworzy odcisk każdego błędnego węzła z identyfikatora reguły i selektora. Druga asercja działa jak zapadka: pada na wpisach baseline’u, które już nie występują, więc naprawione naruszenie nie zostanie w pliku i nie ukryje później tej samej regresji:

    tests/a11y/scan.spec.ts
    import { readFileSync } from 'node:fs';
    import { test, expect } from '@playwright/test';
    import AxeBuilder from '@axe-core/playwright';
    const ROUTES = ['/', '/signup', '/login', '/dashboard', '/settings'];
    const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
    const BLOCKING = new Set(['critical', 'serious']);
    const baseline: Record<string, string[]> = JSON.parse(
    readFileSync('tests/a11y/baseline.json', 'utf8'),
    );
    for (const route of ROUTES) {
    test(`no new WCAG A/AA violations on ${route}`, async ({ page }) => {
    await page.goto(route);
    const { violations } = await new AxeBuilder({ page }).withTags(TAGS).analyze();
    const found = violations
    .filter((v) => BLOCKING.has(v.impact ?? ''))
    .flatMap((v) => v.nodes.map((n) => `${v.id} ${n.target.join(' ')}`));
    const known = new Set(baseline[route] ?? []);
    expect(found.filter((f) => !known.has(f))).toEqual([]);
    const stale = [...known].filter((k) => !found.includes(k));
    expect(stale, 'remove fixed entries from baseline.json').toEqual([]);
    });
    }
  3. Wygeneruj raz tests/a11y/baseline.json z bieżących naruszeń (robi to pierwszy prompt poniżej), zacommituj go i przeglądaj jako listę długów, nie jako akceptację.

  4. Chroń baseline, konfigurację skanu i workflowy CI. Dodaj tests/a11y/, .github/, playwright.config.ts i package.json do CODEOWNERS z właścicielem dostępności i włącz w ochronie gałęzi Require review from Code Owners, żeby pull request agenta nie mógł dopisać wpisów do baseline’u, zmienić tagów ani poprawić samej bramki bez zgody człowieka. Dwa ostatnie pliki są ważne, bo bramka przez nie przechodzi: testIgnore: '**/a11y/**' w playwright.config.ts albo skrypt testowy w package.json, który pomija tests/a11y, zostawia zielony job e2e, choć żaden test dostępności się nie uruchomił. Jeśli CI wywołuje komendę testów z innego pliku, chroń także ten plik. Agent nadal może edytować te pliki; bez tego ustawienia CODEOWNERS tylko prosi o review i nie blokuje merge’a. Ogólny wzorzec opisuje ochrona wyroczni.

  5. Uruchamiaj bramkę w tym samym jobie CI co testy end-to-end. Korzysta z serwera deweloperskiego i przeglądarek, które przygotowały już testy end-to-end.

Dwie decyzje w tym teście skanu ważą więcej, niż się wydaje.

Waga to nie poziom WCAG. Pole impact w axe-core ocenia szkodę dla użytkownika; każda reguła z wybranych tagów to naruszenie poziomu A lub AA. Blokowanie tylko critical i serious to próg początkowy, który będziesz stopniowo zaostrzać. Gdy baseline będzie pusty, dodaj moderate do BLOCKING.

Skanuj stany, nie tylko ścieżki. axe-core sprawdza DOM, który istnieje w chwili wywołania analyze(). Zamknięte menu, nieotwarty dialog i formularz przed walidacją są dla niego niewidoczne. Po otwarciu dialogu przeskanuj go osobno: new AxeBuilder({ page }).include('[role="dialog"]').withTags(TAGS).analyze().

Reguły jsx-a11y łapią część błędów, zanim strona się wyrenderuje: <img> bez alt, obsługę kliknięcia na <div> bez obsługi klawiatury, błędny atrybut ARIA. Dostarczają je dwa lintery.

W oxlint reguły są wbudowane. Dopisz jsx-a11y do wtyczek, których już używasz w .oxlintrc.json:

{ "plugins": ["typescript", "unicorn", "oxc", "jsx-a11y"] }

Pole plugins zastępuje domyślny zestaw wtyczek oxlint, a nie go rozszerza, więc samo "plugins": ["jsx-a11y"] po cichu wyłącza reguły typescript, unicorn i oxc (sprawdzone przez oxlint --print-config w wersji 1.81.0 i w schemacie konfiguracji wersji 1.86.0).

W ESLint zainstaluj wtyczkę i dodaj jej zalecaną konfigurację flat. eslint-plugin-jsx-a11y 6.10.2 (stan na 2026-09-26) deklaruje zakres peer kończący się na ESLint 9, a sam ESLint jest w wersji 10.11.0, więc w projekcie na ESLint 10 zwykła instalacja kończy się błędem ERESOLVE. Na ESLint 10 użyj wbudowanych reguł oxlint opisanych wyżej albo zainstaluj wtyczkę z --legacy-peer-deps i raz uruchom job lintera, żeby potwierdzić, że się ładuje:

Okno terminala
npm install -D eslint-plugin-jsx-a11y # ESLint 9 or earlier
npm install -D eslint-plugin-jsx-a11y --legacy-peer-deps # ESLint 10
eslint.config.js
import jsxA11y from 'eslint-plugin-jsx-a11y';
export default [
// ...your existing config
jsxA11y.flatConfigs.recommended,
];

W obu przypadkach job lintera musi być wymaganym checkiem, jak opisują bramki jakości kodu.

Baseline to kolejka pracy. Dawaj agentowi jedną ścieżkę albo jedną regułę naraz i oceniaj wynik po skanie i diffie, a nie czytając każdą zmienioną linię.

Kontrast to reguła, którą agent łatwo „naprawia” w złym miejscu. Gdy color-contrast pada na wielu stronach, przyczyną jest zwykle jeden token designu. Każ agentowi zmienić token w pliku motywu i trzymać się palety, a potem sprawdź zmianę na zrzucie ekranu, zamiast akceptować wartość szesnastkową na oko.

Tani mechaniczny detektor łapie drugie typowe oszustwo: wyciszenie skanera zamiast naprawy strony. CODEOWNERS go nie złapie, bo działa według ścieżek plików, a aria-hidden może trafić do dowolnego komponentu. Dlatego job poniżej przy trafieniu pada, prosi właścicieli dostępności o review i przechodzi dopiero wtedy, gdy jeden z nich zatwierdzi pull request:

.github/workflows/a11y-silencing.yml
name: a11y-silencing
on:
pull_request:
pull_request_review:
types: [submitted]
permissions:
contents: read
pull-requests: write
jobs:
silencing-detector:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # the default shallow clone has no base branch to diff against
persist-credentials: false # gh reads the token from GH_TOKEN; keep it out of .git/config
- name: Block hidden content and silenced axe without owner approval
shell: bash # explicit bash adds pipefail: a failing pipeline fails the job
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
BASE: ${{ github.event.pull_request.base.ref }}
A11Y_OWNERS: "alice bob" # GitHub logins of the accessibility owners
run: |
# a failed diff stops the job here (fail closed), instead of reading as "no hits"
diff=$(git diff "origin/$BASE...HEAD" -- . ':!tests/a11y/baseline.json')
hits=$(printf '%s\n' "$diff" \
| grep -iE '^\+.*(disableRules|\.exclude\(|aria-hidden|role="presentation"|tabindex)' || true)
[ -z "$hits" ] && exit 0
echo "$hits"
approvers=$(gh pr view "$PR" --json reviews \
--jq '.reviews[] | select(.state == "APPROVED") | .author.login')
for owner in $A11Y_OWNERS; do
echo "$approvers" | grep -qx "$owner" && exit 0
done
gh pr edit "$PR" --add-reviewer "${A11Y_OWNERS// /,}"
echo "::error::This change hides content or silences axe; an accessibility owner must approve it"
exit 1

Ustaw silencing-detector jako wymagany status check i włącz w ochronie gałęzi Dismiss stale pull request approvals when new commits are pushed, żeby zgoda udzielona przed kolejnym pushem agenta nie przechodziła na nowe commity. Job potrzebuje fetch-depth: 0 w actions/checkout: domyślny checkout pobiera jeden commit, więc brakuje gałęzi bazowej i git diff pada. Skrypt zawodzi w bezpieczną stronę: nieudany git diff albo nieudane wywołanie gh kończy job na czerwono, zamiast udawać „brak trafień”. persist-credentials: false trzyma token poza .git/config: wywołania gh czytają go z GH_TOKEN, więc checkout nie ma powodu zapisywać tokenu, który może pisać do pull requestów. Pull request z forka dostaje token tylko do odczytu, więc tam gh pr edit pada i właściciela trzeba poprosić o review ręcznie.

Ten check jest tak wiarygodny jak jego plik workflow. Przy pull_request GitHub uruchamia plik workflow z commita merge’owego samego pull requesta, więc pull request agenta może przerobić a11y-silencing.yml na exit 0 i wymagany check zrobi się zielony. Zabezpieczeniem jest wpis .github/ w CODEOWNERS z wymaganym review właściciela kodu z kroku 4: każda zmiana samej bramki trafia wtedy do właściciela.

Trafienie nie zawsze oznacza błąd: dekoracyjna ikona w przycisku z etykietą powinna mieć aria-hidden="true". Chodzi o to, żeby decydował człowiek, a nie agent, któremu zależało na zielonym buildzie.

Pisz testy klawiatury z getByRole i nazwami dostępnymi. Taki test pada, gdy przycisk traci nazwę, więc bez dodatkowego kosztu działa też jako asercja dostępności. getByTestId przejdzie na przycisku, którego nikt nie potrafi zidentyfikować.

tests/a11y/keyboard.spec.ts
import { test, expect } from '@playwright/test';
test('delete-account dialog traps focus and returns it', async ({ page }) => {
await page.goto('/settings');
const trigger = page.getByRole('button', { name: 'Delete account' });
await trigger.focus(); // shortcut; see the stricter Tab loop below
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: 'Delete account?' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();
for (let i = 0; i < 6; i++) {
await page.keyboard.press('Tab');
await expect(dialog.locator(':focus')).toHaveCount(1); // focus never leaves the dialog
}
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});

trigger.focus() przeskakuje prosto na przycisk, więc ten test przejdzie także wtedy, gdy klawiszem Tab nie da się do przycisku dotrzeć. Jeśli wyzwalacz musi być osiągalny z klawiatury, zastąp tę linię ograniczoną pętlą Tab od początku strony:

for (let i = 0; i < 40 && !(await trigger.evaluate((el) => el === document.activeElement)); i++) {
await page.keyboard.press('Tab');
}
await expect(trigger).toBeFocused(); // fails if 40 Tabs never reach it

Skany na poziomie komponentów w Jest lub Vitest (jest-axe, vitest-axe) są szybkie, ale działają w jsdom, a README jest-axe mówi wprost, że sprawdzanie kontrastu kolorów tam nie działa. Kontrast zostaw w bramce przeglądarkowej opisanej wyżej.

Pozwól agentowi audytować w prawdziwej przeglądarce

Dział zatytułowany „Pozwól agentowi audytować w prawdziwej przeglądarce”

Skaner widzi jeden stan DOM; agent z przeglądarką przejdzie całą ścieżkę, otworzy każde menu, celowo źle wypełni formularz i odczyta drzewo dostępności tak, jak robi to czytnik ekranu. Playwright MCP (@playwright/mcp, 0.0.82 w npm na dzień 2026-09-26) steruje przeglądarką właśnie przez to drzewo. Konfiguracja różni się między narzędziami; prompt pod nią jest ten sam we wszystkich trzech.

Okno terminala
# --isolated: an in-memory profile, so parallel agents do not fight over one browser
claude mcp add playwright -- npx @playwright/mcp@latest --isolated

Ostatnia linia jest tu najważniejsza: ustalenie z audytu eksploracyjnego ma wartość dopiero wtedy, gdy staje się testem w repozytorium, który bez poprawki pada.

Do statycznego przeglądu kodu UI, zanim cokolwiek się uruchomi, służy skill web-design-guidelines od Vercel Labs. Sprawdza komponenty według jego Web Interface Guidelines, w tym reguł dostępności i fokusu, i zgłasza ustalenia w formacie plik:linia. Instalacja: npx skills add vercel-labs/agent-skills --skill web-design-guidelines (CLI skills 1.7.0). Więcej w poradniku o instalowaniu skilli i zarządzaniu nimi oraz w przeglądzie skilli frontendowych.

Nikt nie czyta każdego diffu agenta w poszukiwaniu atrybutów ARIA. Dowód dają bramki, a jedna osoba odpowiada za to, czego maszyna nie oceni.

  • CI, blokujące: bramka baseline’u axe, testy klawiatury i reguły lintera jsx-a11y. Pull request czerwony na którejkolwiek z nich nie trafia do gałęzi głównej.
  • Właściciel dostępności, przy zmianie: zatwierdza przez CODEOWNERS z wymaganym review właściciela kodu każdą edycję baseline.json, testu skanu, playwright.config.ts, package.json i katalogu .github/ oraz zatwierdza każdy pull request oznaczony przez detektor wyciszania, który pozostaje czerwony, dopóki jeden z właścicieli go nie zatwierdzi.
  • Sędzia z rubryką, doradczo: ocenia nowe teksty alt i komunikaty błędów według rubryki i oznacza słabe dla właściciela. Model, który napisał treść, nie ocenia sam siebie; zobacz kontrole oceniane przez model.
  • Człowiek z czytnikiem ekranu, przy każdym wydaniu: przechodzi zmienione ścieżki w VoiceOver lub NVDA według listy kontrolnej przygotowanej przez agenta i zapisuje wynik.

Dołącz diff wyniku axe w JSON i wynik listy kontrolnej do pull requesta lub wydania jako część jego pakietu dowodów, żeby audytor albo dział prawny widział, co sprawdzono i kiedy.

Pierwszy skan zgłasza setki naruszeń. Nie proś agenta o naprawę wszystkiego w jednym pull requeście: diffu nie da się przejrzeć, a bramka dalej jest wyłączona. Zacommituj baseline, włącz bramkę i spłacaj dług regułami. Zwykle kilka współdzielonych komponentów odpowiada za większość wpisów, więc zacznij od reguły z największą liczbą wpisów.

Agent robi zielony build, ukrywając problem. aria-hidden, role="presentation", disableRules i po cichu rosnący baseline przechodzą skan; naprawione naruszenie pozostawione w baseline’ie ukryłoby własny powrót, dlatego skan pada też na nieaktualnych wpisach. Wycofaj te linie, trzymaj baseline, konfigurację skanu, playwright.config.ts, package.json i .github/ pod CODEOWNERS z wymaganym review właściciela kodu i ustaw detektor wyciszania jako wymagany check na każdym pull requeście.

Odciski zmieniają się po nieszkodliwym refaktorze. Selektory axe-core podążają za DOM, więc przemianowany wrapper zmienia odcisk, a „nowe” naruszenie jest starym. Wygeneruj baseline od nowa w osobnym pull requeście zatwierdzonym przez właściciela i sprawdź, czy liczba wpisów na regułę nie wzrosła.

color-contrast trafia do incomplete, a nie do violations. axe-core nie potrafi rozstrzygnąć kontrastu na obrazach tła, gradientach ani nakładających się elementach, więc zgłasza te węzły jako wymagające przeglądu. Bramka powyżej je pomija; dopisz te komponenty do listy kontrolnej czytnika ekranu i przeglądu wizualnego, zamiast zakładać, że przechodzą.

Test klawiatury przechodzi, a komponent dalej jest nieużywalny. Test, który sześć razy naciska Tab, dowodzi, że fokus zostaje w dialogu, a nie że kolejność ma sens. Weź kolejność tabulacji ze snapshotu audytu eksploracyjnego i sprawdzaj ją jawnie w formularzach o najwyższym ryzyku.

Dostępny kod nie pasuje do design systemu. Podaj ograniczenia w prompcie albo w pliku instrukcji agenta: których tokenów używać dla obwódki fokusu, jaki jest minimalny wskaźnik fokusu i które komponenty są zatwierdzonymi dostępnymi prymitywami. Kontrast poprawiaj w tokenie, nigdy jednorazowym kolorem.