Wszystkie wpisy

Automatyzacja a11y w Playwright - axe-core i nazwa dostępna (sierpień 2024)Automatyzacja a11y w Playwright - axe-core i nazwa dostępna (sierpień 2024)

English

Automatyzacja a11y w Playwright - axe-core i nazwa dostępna (sierpień 2024)

Sierpień 2024: AxeBuilder 4.9 i toHaveAccessibleName w Playwright 1.46. Nie certyfikat WCAG i nie visual z 2022.

AutomatyzacjaPlaywright

Ładny przycisk, puste drzewo

Jest 15 sierpnia. W listopadzie 2022 porównywałem narzędzia do wizualnej regresji i zamknąłem tamten wpis zdaniem, pod którym podpisuję się do dziś: wizualna regresja odpowiada wyłącznie na pytanie, czy ekran wygląda tak jak wczoraj. Od tamtej pory toHaveScreenshot chodzi u mnie w nocnym przebiegu i robi dokładnie to, co obiecał.

Na syntetycznym formularzu logowania z mojego repozytorium z przykładami do bloga ten przebieg jest zielony. Przycisk wysyłający formularz to okrągła ikona ze strzałką. Stoi tam, gdzie powinien, i na zrzucie wygląda dokładnie tak jak na wzorcu. A jednak w drzewie dostępności ten przycisk nie ma nazwy. Czytnik ekranu powie “przycisk” i nic więcej, więc ktoś, kto nie widzi strzałki, musi zgadywać, czy to logowanie, czy zamknięcie okna. Nad polem hasła stoi napis “Hasło”, tylko że to zwykły span, z polem niczym niepowiązany. Na ekranie żadnego z tych błędów nie widać, a wzorzec PNG nie ma jak ich zauważyć, bo brak nazwy nie zmienia ani jednego piksela.

W sierpniu dokładam więc do tej samej suite Playwrighta automat a11y. Nie zatrudniam audytora i nie zakładam osobnego projektu. Dopisuję kilka linii do speca, który już chodzi na ścieżce logowania.

Moja teza na sierpień 2024 jest wąska i będę jej pilnował do końca. Automat a11y w Playwrighcie to dwa tanie sygnały na tym samym załadowaniu strony. Pierwszy to katalog reguł axe-core, czyli AxeBuilder i jego analyze(). Drugi to twarda asercja na nazwę i rolę jednej kontrolki, czyli toHaveAccessibleName i toHaveRole. Żaden z nich nie zastępuje czytnika ekranu ani audytu WCAG. Żaden nie jest też wizualną regresją z listopada 2022: piksele i drzewo dostępności łapią różne regresje, co za chwilę pokażę na jednym formularzu.

Stan warsztatu, żeby za rok było wiadomo, o czym mówię. Runner to @playwright/test w wersji 1.46.0 z 5 sierpnia. To wydanie nie wnosi nic do dostępności: przynosi certyfikaty klienta TLS, flagę --only-changed i zmiany w UI mode. Przypinam je, bo to bieżące wydanie, a nie dlatego, że czegoś z niego potrzebuję do a11y. Asercje, na których stoi ten wpis, czyli toHaveAccessibleName, toHaveAccessibleDescription i toHaveRole, są w runnerze od 1.44.0 z 6 maja. Skaner to @axe-core/playwright w wersji 4.9.1 z 20 maja. Paczka trzyma axe-core w zakresie ~4.9.1, więc silnik reguł też stoi u mnie na 4.9.1. WCAG 2.2 jest rekomendacją W3C od 5 października 2023 i w tym wpisie służy za mapę, a nie za certyfikat, który wystawiam sobie po zielonym jobie.

Czego tu nie będzie. Nie będzie kursu audytora ani listy kryteriów sukcesu przepisanej z normy. Nie będzie wyników prawdziwych audytów ani zrzutów produkcyjnych formularzy, bo formularz logowania klienta to dane, a nie ilustracja do bloga. Nie będzie też modelu językowego, który “naprawia a11y”. GPT-4o mini jest od 18 lipca, ale to, jak ma się nazywać przycisk, jest decyzją o produkcie, a nie poprawką do wygenerowania. Będzie syntetyczny formularz z dwoma celowymi błędami, AxeBuilder i dwie asercje w jednym teście, dwa refaktory, przy których axe milczy, a asercje nie, i trzy fałszywe pewności do skreślenia.

Dwa sygnały, nie jeden skaner

Demo jest syntetyczne i mówię to wprost, zanim pokażę listing. Strona to <main> z nagłówkiem h1 i tym formularzem:

<form id="login">
  <label for="email">E-mail</label>
  <input id="email" type="email" data-testid="login-email">

  <span class="field-label">Hasło</span>
  <input id="password" type="password" data-testid="login-password">

  <button type="submit" class="icon-btn" data-testid="login-submit">
    <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M10 17l5-5-5-5z"/></svg>
  </button>
</form>

Błędy są dwa i oba wstawiłem celowo. Pierwszy to span zamiast label: napis “Hasło” stoi nad polem, ale nic go z tym polem nie łączy. Drugi to przycisk z samą ikoną. SVG ma aria-hidden="true", jak przystało na ikonę dekoracyjną, więc przycisk nie dostaje nazwy ani z treści, ani z atrybutu.

Pierwszy sygnał to katalog. AxeBuilder przy analyze() wstrzykuje axe-core do strony i uruchamia reguły na tym DOM, który akurat jest wyrenderowany. Zawężam je przez withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) do reguł przypisanych kryteriom WCAG 2.0 na poziomach A i AA oraz WCAG 2.1 na poziomie AA. Przykład w dokumentacji Playwrighta dokłada czwarty tag, wcag21a. Sprawdziłem, co to zmienia na silniku 4.9.1: pod tym tagiem jest jedna reguła, oznaczona jako eksperymentalna, a oba warianty uruchamiają na moim formularzu te same 62 reguły.

Na zepsutym formularzu katalog zgłasza naruszenia dwóch reguł: button-name i label. Obie mają impact równe critical i obie są przypisane do kryterium 4.1.2, czyli nazwy, roli i wartości. Do tego katalog nadaje się najlepiej. Zna wzorzec “przycisk bez rozpoznawalnej nazwy” i wzorzec “pole formularza bez etykiety” i znajduje je na całej stronie, nie tylko tam, gdzie akurat patrzę.

Drugi sygnał to przypięcie jednej kontrolki. Katalog nie wie, jak ma się nazywać mój przycisk. Wie tylko, że jakaś nazwa powinna być. Tę wiedzę mam ja, więc zapisuję ją w dwóch asercjach: toHaveRole('button') i toHaveAccessibleName('Zaloguj').

Lokator jest celowo po data-testid, a nie po roli. W maju 2022, stawiając pusty projekt, odłożyłem lokatory po rolach na później. getByRole jest od wersji 1.27 i dziś używam go na co dzień, tylko nie tutaj. getByRole('button', { name: 'Zaloguj' }) już filtruje po nazwie, więc kiedy nazwy brakuje, lokator nic nie znajduje i asercja kończy się komunikatem <element(s) not found>. To też jest czerwony wynik, ale mówi “nie ma elementu”, a nie “element jest, tylko nie ma nazwy”. Test id to kontrakt z programistą, nazwa dostępna to kontrakt z użytkownikiem, i trzymam je osobno, żeby asercja miała co sprawdzać. Notatki wydania 1.44.0 pokazują toHaveRole w tym samym układzie, na getByTestId.

Złożone w jeden test wygląda to tak:

import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'

test('logowanie: dym a11y', { tag: ['@smoke', '@a11y'] }, async ({ page }) => {
  await page.goto('/login/')

  const { violations } = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
    .analyze()
  const blocking = violations.filter(v => v.impact === 'serious' || v.impact === 'critical')
  expect.soft(blocking.map(v => v.id)).toEqual([])

  const submit = page.getByTestId('login-submit')
  await expect(submit).toHaveRole('button')
  await expect(submit).toHaveAccessibleName('Zaloguj')
})

Dwie rzeczy w tym listingu nie są ozdobą. expect.soft przy katalogu sprawia, że czerwony wynik axe nie przerywa testu, więc jeden przebieg pokazuje oba sygnały, a nie tylko pierwszy. Filtr po impact to decyzja o bramce i wracam do niej w następnej sekcji. Na zepsutym formularzu wynik wygląda tak (skróciłem log wywołań, w którym ta sama para linii powtarza się przy każdej próbie):

  1) [chromium] › login.a11y.spec.ts:4:5 › logowanie: dym a11y

    Error: expect(received).toEqual(expected) // deep equality

    - Expected  - 1
    + Received  + 4

    - Array []
    + Array [
    +   "button-name",
    +   "label",
    + ]

    Error: Timed out 5000ms waiting for expect(locator).toHaveAccessibleName(expected)

    Locator: getByTestId('login-submit')
    Expected string: "Zaloguj"
    Received string: ""
    Call log:
      - expect.toHaveAccessibleName with timeout 5000ms
      - waiting for getByTestId('login-submit')
      -   locator resolved to <button type="submit" class="icon-btn" data-testid="login-submit">…</button>
      -   unexpected value ""

toHaveRole('button') przeszło, bo element jest prawdziwym <button>. Padła nazwa: oczekiwałem “Zaloguj”, a dostałem pusty napis.

Poprawka mieści się w dwóch liniach. Przycisk dostaje aria-label="Zaloguj", a span nad hasłem zamienia się w <label for="password">. Po tej zmianie oba sygnały milkną. Cały test na lokalnym serwerze trwa 382 milisekundy, a samo analyze() na tym formularzu zajmuje od 190 do 310.

Po co obie warstwy. Kontrolka może przejść axe i mimo to stracić właściwą etykietę albo rolę. Sprawdziłem to na dwóch refaktorach naprawionego formularza, po jednym na każdą asercję.

Pierwszy refaktor to kopiuj-wklej. Ktoś przenosi ikonę z okna dialogowego i razem z nią przenosi aria-label="Zamknij". Dla axe sprawa jest zamknięta: przycisk ma nazwę, button-name przechodzi, a violations jest puste zarówno z tagami, jak i w przebiegu domyślnym. Asercja pada od razu:

    Locator: getByTestId('login-submit')
    Expected string: "Zaloguj"
    Received string: "Zamknij"

Drugi refaktor to przycisk z div. Ktoś chce pełnej kontroli nad stylem i zamienia <button> na div z napisem “Zaloguj” i handlerem onclick. Na ekranie widać napis, kliknięcie myszą działa, a axe znowu nie ma czego zgłosić: handlerów zdarzeń nie widzi, a tekst w div jest dla niego zwykłym tekstem. Z klawiatury do tego elementu nie da się dojść, bo div nie przyjmuje fokusu. Pada toHaveRole:

    Locator: getByTestId('login-submit')
    Expected string: "button"
    Received string: ""

Uczciwie dopiszę, że axe nie jest tu ślepy z zasady. Kiedy na tym samym div zostaje aria-label, axe 4.9.1 odkłada aria-prohibited-attr z impact równym serious do incomplete, bo element ma tekst do przeczytania. Jako naruszenie zgłasza to dopiero przy samej ikonie. Bramka na pull requeście patrzy tylko na violations, więc wariant z napisem zobaczy dopiero nocny raport z incomplete. Katalog łapie wszystko, co da się zapisać jako regułę. Zdanie “ta ikona ma się nazywać Zaloguj” nie jest regułą, tylko wymaganiem, i dlatego siedzi w asercji.

Gdzie w CI

Miejsce dla tego testu wyznaczyłem, zanim w ogóle o nim pomyślałem. We wrześniu 2022 podzieliłem CI na trzy koszyki: pull request, main po merdżu i nocny harmonogram. Na pull request wchodzi tylko to, co jest szybkie i deterministyczne, plus jeden smoke UI z własnym stanem, bo wymagany check ma być mały i stabilny. Dym a11y spełnia ten warunek lepiej niż większość testów przez przeglądarkę: czyta DOM zamiast porównywać piksele i na formularzu logowania kosztuje ułamek sekundy. Dopinam go więc do tego jednego smoke’a na ścieżce logowania, a nie do całej aplikacji. Stąd w teście tag @smoke obok @a11y: job pull requesta odpala npx playwright test --project=chromium --grep @smoke i bierze dym a11y razem z resztą.

Bramka ma dwa poziomy. Pierwszy to tagi. Przebieg domyślny uruchamia na tym formularzu 88 reguł, przebieg z moimi trzema tagami 62. Różnicę robią głównie reguły z tagiem best-practice, które nie są przypisane do żadnego kryterium WCAG: landmarki, kolejność nagłówków, region, tabindex. Drugi poziom to impact. Skala w axe ma cztery stopnie, minor, moderate, serious i critical, a poziomu HIGH w niej nie ma. Na pull requeście czerwień dostają tylko dwa najwyższe.

Dlaczego nie wszystko na każdy push, skoro jest tanio? Bo kosztem nie jest czas, tylko uwaga. Ten sam zepsuty formularz bez <main> i bez h1 daje w przebiegu domyślnym trzy dodatkowe naruszenia: landmark-one-main, page-has-heading-one i region, wszystkie moderate i wszystkie best-practice. To są prawdziwe uwagi i warto je poprawić. Nie są jednak powodem, żeby zablokować komuś pull request z literówką w README. Job, który świeci na czerwono przez regułę, której nikt nie naprawi w tym sprincie, uczy zespół, że wynik bramki jest opcjonalny. We wrześniu 2022 zapisałem, że od tego momentu bramka nie chroni już przed niczym.

Pełniejszy skan idzie do nocnego koszyka. Tam axe chodzi bez tagów i w dwóch projektach, jasnym i ciemnym, bo motyw to osobny render, o czym za chwilę:

// playwright.config.ts, fragment
projects: [
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  {
    name: 'chromium-dark',
    use: { ...devices['Desktop Chrome'], colorScheme: 'dark' },
    grep: /@a11y/,
  },
],

Job pull requesta odpala tylko projekt chromium, więc ciemny motyw chodzi wyłącznie w nocy. Nocna bramka jest ta sama, serious i critical, ale do raportu trafia pełny wynik razem z incomplete, przez testInfo.attach, tak jak pokazuje dokumentacja Playwrighta:

test('nocny skan a11y', { tag: '@a11y' }, async ({ page }, testInfo) => {
  await page.goto('/login/')
  const results = await new AxeBuilder({ page }).analyze()

  await testInfo.attach('axe-results', {
    body: JSON.stringify({ violations: results.violations, incomplete: results.incomplete }, null, 2),
    contentType: 'application/json',
  })

  const blocking = results.violations.filter(v => v.impact === 'serious' || v.impact === 'critical')
  expect(blocking.map(v => v.id)).toEqual([])
})

Na demie mam jeden ekran. Przy większej aplikacji ten sam test idzie w pętli po kilku kluczowych ekranach, a nie po wszystkich. Raport czyta rano właściciel nocnego przebiegu. Dwa lata temu pisałem, że bez nazwiska w kalendarzu ten koszyk umiera w trzy tygodnie, i dym a11y niczego w tym nie zmienia.

Znane problemy, których nie naprawię od ręki, wyłączam punktowo, a nie progiem. exclude() zdejmuje ze skanu element razem z całym poddrzewem i ze wszystkich reguł naraz, więc biorę go tylko do małych, nazwanych elementów. Do jednej reguły jest disableRules(). Każde takie wyłączenie dostaje w komentarzu numer zgłoszenia. Obniżenie bramki do samego critical dla całej aplikacji, bo jeden widżet ma problem, to ten sam ruch co dopisanie ponowień do migoczącego testu: wynik robi się zielony, a problem znika tylko z raportu.

Jeszcze jedna uwaga o nowości z 1.46.0. Flaga --only-changed według dokumentacji uruchamia tylko te pliki testów, które zmieniły się względem wskazanego commita. Do dymu a11y się nie nadaje. Regresja dostępności przychodzi w markupie aplikacji, a zmiana w szablonie przycisku nie zmienia speca e2e, więc test, który ma ją złapać, w ogóle by nie wystartował.

Czego automat nie widzi

W 2019 roku, opisując proces testowy, do którego dążę, napisałem, że powtarzalną pracę zostawiamy robotom, a ludzie mają się skupić na tym, w czym są najlepsi. W dostępności ta granica jest wyjątkowo ostra. Skan axe i dwie asercje to praca powtarzalna. Ocena, czy formularz da się przejść i zrozumieć, nie jest. Dokumentacja Playwrighta mówi to zresztą wprost na stronie o testach dostępności: automat wykrywa część typowych problemów, a wiele innych da się znaleźć tylko ręcznie.

Na tym samym formularzu sprawdziłem cztery sytuacje, w których test jest zielony, a problem jest.

Kolejność fokusa. Odwróciłem kolejność elementów formularza jedną deklaracją flex-direction: column-reverse. Na ekranie przycisk stoi teraz na górze, a pole e-mail na samym dole, ale Tab nadal idzie w kolejności DOM: e-mail, hasło, przycisk, czyli od dołu do góry. Skan nie zgłasza niczego nawet w przebiegu domyślnym, a obie asercje przechodzą, bo nazwa i rola się nie zmieniły. Przejście z klawiatury da się zapisać w teście, przez press('Tab') i toBeFocused(), ale oczekiwaną kolejność i tak wpisuje człowiek, który wie, jak ten formularz się czyta.

Sens etykiety. Reguły axe sprawdzają, czy nazwa istnieje, a nie, czy mówi prawdę. Wariant z “Zamknij” przeszedł katalog bez słowa. Asercja złapała go tylko dlatego, że wpisałem do niej “Zaloguj”, a to “Zaloguj” też jest moją decyzją, nie narzędzia.

Kontrast w konkretnym motywie. Dopisałem do strony ciemny motyw pod prefers-color-scheme: dark, z tekstem #4a4a4a na tle #1e1e1e. W zwykłym, jasnym przebiegu axe jest zielony, bo ciemnego motywu nigdy nie zobaczył: sprawdza to, co jest wyrenderowane w chwili analyze(). Dopiero z colorScheme: 'dark' zgłasza color-contrast z impact równym serious. Nagłówek ma tam kontrast 1,88 przy wymaganym 3:1 dla dużego tekstu.

Kontrast, którego axe nie umie policzyć. Krótki komunikat na gradiencie trafia nie do violations, tylko do incomplete, czyli na listę rzeczy do ręcznego sprawdzenia. Test, który patrzy wyłącznie na violations, jest wtedy zielony, a o tej liście nikt się nie dowie, jeśli nie trafi do raportu.

Czytnik i człowiek zostają. Przed wydaniem przechodzę ścieżkę krytyczną raz samą klawiaturą i raz z czytnikiem ekranu. To nie jest audyt i nie udaję, że jest. To jest minimum, bez którego zielony dym nic mi nie mówi o tym, czy ktoś się zaloguje.

Trzy fałszywe pewności z tego tygodnia, wszystkie do skreślenia.

Skreślenie pierwsze: “WCAG AA zaliczone”. Puste violations na trzech tagach to 62 reguły na jednym adresie, w jednym motywie i przy jednym rozmiarze okna. Reguły uruchamiane z tymi tagami dotykają 20 z 55 kryteriów sukcesu WCAG 2.2 na poziomach A i AA, i to zwykle częściowo: image-alt sprawdza, czy obrazek ma tekst alternatywny, a nie, czy ten tekst coś znaczy. Z dziewięciu kryteriów, które dodało WCAG 2.2, axe 4.9.1 ma regułę na jedno, target-size, i nie uruchamia jej ani domyślnie, ani z moimi tagami; trzeba o nią poprosić tagiem wcag22aa. Zgodność z WCAG deklaruje się dla całych stron i kompletnych procesów, na wszystkich kryteriach wybranego poziomu. Z jednego zielonego joba nie wynika ani ona, ani żadna deklaracja, którą dałoby się komuś pokazać.

Skreślenie drugie: “puste violations to czysty kontrast”. Gradient wylądował w incomplete, a ciemnego motywu axe w ogóle nie zobaczył. W obu przypadkach violations było puste. Dlatego nocny przebieg ma projekt z colorScheme: 'dark' i dokłada incomplete do raportu, a ja nie piszę w zgłoszeniu “kontrast OK” na podstawie pustej tablicy.

Skreślenie trzecie: “jest nazwa, więc jest etykieta”. Usunąłem <label> nad hasłem i zostawiłem placeholder="Hasło". Reguła label przepuszcza takie pole, bo placeholder liczy się w axe jako nazwa. Playwright liczy nazwę tak samo, więc nawet toHaveAccessibleName('Hasło') na tym polu byłoby zielone. Czytnik ma co przeczytać, ale widzący użytkownik traci jedyną etykietę przy pierwszym wpisanym znaku. Gdy w placeholderze zamiast nazwy siedzi podpowiedź, na przykład “Min. 12 znaków”, Playwright zwraca właśnie ten napis jako nazwę pola, a axe nadal milczy. Asercja na polu złapałaby wtedy różnicę. Moje asercje przypinają przycisk, a nie pole, więc nie łapią nic. Przypięcie chroni tylko to, co przypiąłem.

Kontrast z visual

Visual łapie layout. A11y łapie nazwę, rolę i etykietę. Na tym formularzu da się to pokazać bez jednego zdania teorii.

Zrobiłem zrzuty trzech wersji: zepsutej, naprawionej i tej z “Zamknij”. Wszystkie trzy pliki PNG są identyczne co do bajtu. toHaveScreenshot nie znalazłby tu żadnej różnicy, bo aria-label i zamiana span na label nie zmieniają ani jednego piksela. Wizualny wzorzec nie zauważy ani błędu, ani poprawki, ani regresji.

W drugą stronę działa to tak samo. Przesunąłem przycisk o 96 pikseli w prawo. Zrzut różni się od wzorca, więc wizualna bramka zrobi swoje. Skan axe jest zielony i obie asercje przechodzą, bo przycisk nadal jest przyciskiem i nadal nazywa się “Zaloguj”. Oba czerwone wyniki są poprawne i każdy przychodzi z innego narzędzia.

Dlatego nie zlewam ich w jeden job “UI quality”. Mają inne koszyki: wizualna regresja siedzi u mnie w nocy od listopada 2022, bo jej wynik zależy od maszyny, która renderowała, a dym a11y siedzi na pull requeście, bo nie porównuje pikseli z wzorcem i różnica w wygładzaniu fontów między laptopem a agentem nie zmienia mu wyniku. Mają inną akceptację: zmianę wyglądu akceptuję nowym wzorcem i commitem, a w a11y nie ma wzorca do nadpisania, jest markup do poprawienia albo jawne wyłączenie z numerem zgłoszenia. I mają inną historię czerwieni. Gdyby siedziały w jednym jobie, dym a11y odziedziczyłby reputację nocnego visuala: czerwone, pewnie znowu fonty, ponów. Ten odruch, ponowienie bez czytania logu, opisałem we wrześniu 2022 jako moment, w którym bramka zamienia się w formalność.

Wtyczkę do Cypressa z marca 2020 zostawiam w tej rozmowie z boku. To była wizualna regresja w innym runnerze, z wzorcem PNG i porównaniem obrazów, czyli ta sama oś co toHaveScreenshot, a nie oś drzewa dostępności. W podsumowaniu 2022 visual dostał osobną sekcję, a a11y nie dostało ani zdania. Sierpień 2024 to nadrabia, ale nie kosztem piksela: dokładam drzewo, wzorców nie kasuję.

Podsumowanie

W sierpniu 2024 tani automat a11y to u mnie AxeBuilder z @axe-core/playwright 4.9.1 i dwie asercje z 1.44 na jednej kontrolce, w jednym teście na Playwright 1.46.0, na tym samym załadowaniu strony. Do tego przejście ścieżki krytycznej klawiaturą i czytnikiem. Zielone axe nie zamyka zgłoszenia dostępności.

Trzy zdania, które chcę móc przeczytać za rok. Na syntetycznym formularzu axe zgłosił button-name i label, oba critical, a toHaveAccessibleName dostało pusty napis zamiast “Zaloguj”, wszystko w jednym przebiegu dzięki expect.soft. Przy refaktorach aria-label="Zamknij" i div zamiast button axe milczał, a padły odpowiednio toHaveAccessibleName i toHaveRole. Zrzuty zepsutego i naprawionego formularza są identyczne co do bajtu, więc wizualny wzorzec nie widzi żadnej z tych zmian.

Czego z tego nie wynika. Nie wynika, że mam certyfikat WCAG 2.2 ani cokolwiek, co da się pokazać jako deklarację zgodności. Nie wynika, że kasuję wizualną regresję, bo przesunięty przycisk widzi tylko ona. Nie wynika też, że asercje zastępują katalog: pole hasła bez <label> złapał wyłącznie axe, bo na tym polu nie miałem żadnej asercji.

Warunek, po którym poznam, że ten automat u mnie dojrzał, formułuję tak. Przez miesiąc nocny raport z incomplete i z ciemnym motywem ma właściciela, który go czyta. Każde wyłączenie reguły ma numer zgłoszenia i datę, po której wraca do skanu. I żadne zgłoszenie dostępności nie zostaje zamknięte tylko dlatego, że tablica violations była pusta.