Mert Özen Her Satırda Daha İleri
Frontend Accessibility (a11y) — #08

Accessibility Test Otomasyonu: axe-core, Lighthouse, Playwright ve ESLint ile CI'da a11y Kontrolü ve Otomasyonun Sınırları

Mert Özen 15 Kas 2026 19 dk 5 görüntülenme
Accessibility Test Otomasyonu: axe-core, Lighthouse, Playwright ve ESLint ile CI'da a11y Kontrolü ve Otomasyonun Sınırları

Accessibility'yi CI'da tek bir araçla değil katmanlarla korursun: angular-eslint, component test'te axe, gerçek tarayıcıda sayfa ve state taraması, regression alarmı olarak Lighthouse ve hâlâ gerekli olan manuel test. Bookstore'da yalnız bir state'te görünen gerçek bir ihlali buluyor, mevcut borcu baseline'layıp yeni ihlalde build'i kıran bir GitHub Actions kapısı kuruyor ve Bakanlık kontrol listesi için A seviyesi bir rapor üretiyoruz.

Sürüm notu: Bu yazıdaki örnekler WCAG (Web Content Accessibility Guidelines; web içeriği için accessibility kılavuzu) 2.2, Angular 22.2 (TypeScript 6.0), angular-eslint 22.6 ile ESLint 10.15, Vitest 5.1, Node.js 24, Playwright 1.65 (Chromium 158), axe-core ve @axe-core/playwright 4.14, Lighthouse 13.6 ve actionlint 1.7 ile denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) dört sayfası serinin başından beri scan.mjs'te sıfır WCAG ihlali, Lighthouse'ta 100 alıyor. Aynı axe'ı bu kez sayfaların yalnız açılış hâlinde değil, kullanıcı bir şey yaptıktan sonraki hâllerinde (öneri listesi açık, hata özeti görünür, silme dialog'u açık) çalıştırırsak kaç ihlal çıkar? İkinci soru: otomatik araçlar accessibility sorunlarının kabaca ne kadarını bulur, yüzde 30 mu, 60 mı, 90 mı? Bu yazı serinin son yazısı; şimdiye kadar elle çalıştırdığımız kontrolleri her pull request'te çalışan bir kapıya çeviriyoruz.

Katmanlar

Tek bir araç her şeyi görmüyor; her biri farklı bir anda, farklı bir maliyetle farklı bir şeyi yakalıyor. Bookstore için kurduğumuz zincir:

08-test-layers-tr.png

En üstteki katmanlar ucuz ve hızlı: editörde ya da birkaç saniyelik bir CI adımında çalışırlar. Aşağı indikçe gerçeğe yaklaşırlar ve pahalılaşırlar; en alttaki manuel test hiçbir zaman tamamen otomatiğe bağlanamaz. Playwright'ın kurulumu ve E2E (end-to-end; uygulamayı uçtan uca, gerçek tarayıcıda test etmek) temeli Integration ve E2E Test serisinde, component test altyapısı Frontend Testleri serisinde, ESLint altyapısı Statik Analiz serisinde; burada yalnız accessibility parçalarını ekliyoruz.

Lint: angular-eslint'in Accessibility Kuralları

angular-eslint'in templateAccessibility config'i template'teki tipik hataları yazarken yakalar: alt'sız img, klavye karşılığı olmayan (click), focus alamayan etkileşimli element, label'ı olmayan form alanı, geçersiz ARIA (WAI-ARIA; HTML element'lerine role, state ve property ekleyen standart) attribute'u, pozitif tabindex. Kuralların ne yakaladığı ve örnek çıktıları HTML serisinin "Accessible Markup Temelleri" yazısında; burada config'i Bookstore'a koyup çalıştırıyoruz:

// @ts-check
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');

module.exports = tseslint.config(
  {
    files: ['**/*.ts'],
    extends: [...tseslint.configs.recommended, ...angular.configs.tsRecommended],
    processor: angular.processInlineTemplates,
  },
  {
    // Template kuralları, accessibility seti dahil (alt text, label'lar, focus alabilen click handler'lar, geçerli ARIA)
    files: ['**/*.html'],
    extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
    rules: {
      '@angular-eslint/template/no-positive-tabindex': 'error',
    },
  },
);
npm install -D eslint@10.15.0 angular-eslint@22.6.0 typescript-eslint@8.72.2
npx eslint src
echo $?
0

Bookstore temiz çıktı; serinin yedi yazısında düzelttiğimiz şeylerin çoğu zaten bu kuralların konusuydu. Bir ayrıntı: no-autofocus kuralı açık, ama sepetteki silme dialog'unun içindeki autofocus için hata vermedi; aynı attribute'u dialog dışındaki bir butona koyduğum deneme dosyasında ise hata verdi. Dialog içinde ilk focus'u belirlemek autofocus'un önerilen kullanımı, kural bunu ayırt ediyor.

Component Test'te axe

Lint template'in metnine bakar; render edilmiş DOM'a (Document Object Model; sayfanın tarayıcıdaki element ağacı) bakmaz. Bir component'in gerçek çıktısını kontrol etmenin en ucuz yolu, component test'inde axe-core'u çalıştırmak. Angular 22'nin unit-test builder'ı Vitest ve jsdom kullanıyor; axe-core jsdom'da çalışıyor, ama layout olmadığı için contrast ve hedef boyutu gibi ölçüm isteyen kuralları burada kapatıyoruz:

import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import axe from 'axe-core';
import { BookDetails } from '../core/api-models';
import { BookDetailPage } from './book-detail-page';

const dune: BookDetails = {
  isbn: '9780441172719', title: 'Dune', author: 'Frank Herbert', category: 'Science Fiction',
  price: 489, publishedYear: 1965, description: 'A desert planet.', available: 25,
};

describe('BookDetailPage accessibility', () => {
  it('has no axe violations once the book has loaded', async () => {
    TestBed.configureTestingModule({ providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] });
    const fixture = TestBed.createComponent(BookDetailPage);
    fixture.componentRef.setInput('isbn', dune.isbn);
    // Bir kez render et ki httpResource isteğini göndersin; whenStable() yanıtı beklerdi
    fixture.detectChanges();
    TestBed.inject(HttpTestingController).expectOne(`/api/books/${dune.isbn}`).flush(dune);
    await fixture.whenStable();

    // jsdom'da layout yok: render isteyen kurallar (color-contrast, target-size) tarayıcı testlerinin işi
    const results = await axe.run(fixture.nativeElement, {
      runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
      rules: { 'color-contrast': { enabled: false } },
    });
    expect(results.violations.map(v => `${v.id}: ${v.nodes.map(n => n.target.join(' ')).join(', ')}`)).toEqual([]);
    expect(results.passes.length).toBeGreaterThan(0);
  });
});

Testin gerçekten bir şey yakaladığını görmek için kitap sayfasındaki "Quantity" etiket metnini sildim:

 ❯ |bookstore-web| src/app/catalog/book-detail-page.a11y.spec.ts (1 test | 1 failed) 98ms
   ❯ BookDetailPage accessibility (1)
     × has no axe violations once the book has loaded 97ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
 FAIL  |bookstore-web| src/app/catalog/book-detail-page.a11y.spec.ts > BookDetailPage accessibility > has no axe violations once the book has loaded
AssertionError: expected [ 'label: input' ] to deeply equal []
- Expected
+ Received
- []
+ [
+   "label: input",
+ ]
 ❯ src/app/catalog/book-detail-page.a11y.spec.ts:29:104
     27|       rules: { 'color-contrast': { enabled: false } },
     28|     });
     29|     expect(results.violations.map(v => `${v.id}: ${v.nodes.map(n => n.…
       |                                                                                                        ^
     30|     expect(results.passes.length).toBeGreaterThan(0);
     31|   });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
 Test Files  1 failed | 1 passed (2)
      Tests  1 failed | 1 passed (2)
   Start at  05:05:10
   Duration  776ms (environment 57%, setup 14%, tests 12%, import 10%, transform 6%)

Etiketi geri koyunca:

 Test Files  2 passed (2)
      Tests  2 passed (2)
   Start at  05:05:12
   Duration  781ms (environment 51%, setup 19%, tests 12%, import 11%, transform 7%, worker 1%)

Bu katmanın gücü hızı (test bir saniyenin altında) ve hatanın hangi component'te olduğunu doğrudan söylemesi. Zayıflığı, component'in yalnız testte kurduğun hâlini görmesi ve layout'a, gerçek CSS'e, sayfa bağlamına (landmark'lar, heading sırası, sayfa başlığı) kör olması.

Sayfalarda ve State'lerde axe: Playwright

Asıl kapı gerçek tarayıcıda. Playwright Test'e @axe-core/playwright ile bir suite ekledik. Sayfaların açılış hâlinin yanında, yalnız bir etkileşimden sonra var olan hâlleri de tarıyor: öneri listesi açık katalog, Details sekmesi seçili kitap sayfası, hata özeti görünen sepet, silme dialog'u açık sepet. Her ihlal "state | kural | CSS hedefi" biçiminde bir satıra dönüşüyor ve a11y-baseline.json'daki satırlarla karşılaştırılıyor:

// Her sayfada ve yalnız bir etkileşimden sonra var olan state'lerde axe (popup açık, hatalar görünür, dialog açık)
// a11y-baseline.json'da olmayan bir ihlal testi kırar; baseline'daki bilinen borç kırmaz
// Baseline'ı bilerek güncelle: A11Y_UPDATE_BASELINE=1 npx playwright test
import { AxeBuilder } from '@axe-core/playwright';
import { expect, test, type Page } from '@playwright/test';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';

const tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'];
const baselineFile = new URL('../a11y-baseline.json', import.meta.url);
const baseline: string[] = JSON.parse(readFileSync(baselineFile, 'utf8'));
const found: string[] = [];
for (const state of states) {
  test(`${state.name}: no new axe violations`, async ({ page }) => {
    await state.open(page);
    const results = await new AxeBuilder({ page }).withTags(tags).analyze();
    // Rapor için ham sonuçlar (report.mjs)
    mkdirSync('reports/axe', { recursive: true });
    writeFileSync(`reports/axe/${state.name.replace(/\W+/g, '-')}.json`, JSON.stringify(results));

    // Her element için bir satır: state | kural | CSS hedefi. Koşular arasında karşılaştırmaya yetecek kadar kararlı
    const lines = results.violations.flatMap(v => v.nodes.map(n => `${state.name} | ${v.id} | ${n.target.join(' ')}`));
    found.push(...lines);
    const fresh = lines.filter(line => !baseline.includes(line));
    if (!process.env.A11Y_UPDATE_BASELINE) {
      expect(fresh, 'violations that are not in a11y-baseline.json').toEqual([]);
    }
  });
}

test.afterAll(() => {
  if (process.env.A11Y_UPDATE_BASELINE) {
    writeFileSync(baselineFile, JSON.stringify([...new Set(found)].sort(), null, 2) + '\n');
  }
  // Ödenmiş borç: satırı baseline'dan silmesini söyle ki aynı sorun fark edilmeden geri gelmesin
  const fixed = baseline.filter(line => !found.includes(line));
  if (fixed.length && !process.env.A11Y_UPDATE_BASELINE) {
    console.log(`Fixed, remove from a11y-baseline.json:\n  ${fixed.join('\n  ')}`);
  }
});
import { defineConfig } from '@playwright/test';

// a11y suite'i: mock API ve build edilmiş uygulama üzerinde Bookstore sayfalarında ve state'lerinde axe
export default defineConfig({
  testDir: 'tests',
  // Tek worker: testler afterAll'daki baseline hesabını paylaşıyor
  workers: 1,
  reporter: process.env.CI ? [['list'], ['json', { outputFile: 'reports/axe-results.json' }]] : 'list',
  use: { baseURL: process.env.BOOKSTORE_URL ?? 'http://127.0.0.1:52411' },
  webServer: {
    command: 'node mock-api/server.mjs',
    cwd: '..',
    url: 'http://127.0.0.1:52411/api/categories',
    reuseExistingServer: !process.env.CI,
  },
});

Baseline boşken ilk çalıştırma:


Running 6 tests using 1 worker

  ✓  1 tests/a11y.spec.ts:47:3 › catalog: no new axe violations (402ms)
  ✓  2 tests/a11y.spec.ts:47:3 › catalog, suggestions open: no new axe violations (405ms)
  ✓  3 tests/a11y.spec.ts:47:3 › book, details tab: no new axe violations (320ms)
  ✘  4 tests/a11y.spec.ts:47:3 › cart, error summary: no new axe violations (421ms)
  ✓  5 tests/a11y.spec.ts:47:3 › cart, remove dialog: no new axe violations (377ms)
  ✓  6 tests/a11y.spec.ts:47:3 › statement: no new axe violations (246ms)


  1) tests/a11y.spec.ts:47:3 › cart, error summary: no new axe violations ──────────────────────────

    Error: violations that are not in a11y-baseline.json

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

    - Expected  - 1
    + Received  + 4

    - Array []
    + Array [
    +   "cart, error summary | target-size | a[href$=\"#customer-name\"]",
    +   "cart, error summary | target-size | a[href$=\"#customer-email\"]",
    + ]

      57 |     const fresh = lines.filter(line => !baseline.includes(line));
      58 |     if (!process.env.A11Y_UPDATE_BASELINE) {
    > 59 |       expect(fresh, 'violations that are not in a11y-baseline.json').toEqual([]);
         |                                                                      ^
      60 |     }
      61 |   });
      62 | }
        at /home/user/bookstore/a11y/tests/a11y.spec.ts:59:70

    Error Context: test-results/a11y-cart-error-summary-no-new-axe-violations/error-context.md

    Error Context: test-results/a11y-cart-error-summary-no-new-axe-violations/error-context.md

  1 failed
    tests/a11y.spec.ts:47:3 › cart, error summary: no new axe violations ───────────────────────────
  5 passed (2.7s)

Tahminin ilk sorusunun cevabı: bir state'te iki ihlal var. Serinin dinamik içerik yazısında eklediğimiz hata özetinin link'leri 2.5.8 Target Size (Minimum) kriterine takılıyor: her link kendi satırında, yüksekliği 24 CSS pikselin altında ve alt alta oldukları için spacing istisnası da kurtarmıyor. Bu link'ler bir cümlenin içinde olsaydı "inline" istisnasına girerdi. Ne scan.mjs ne Lighthouse bunu bulabilirdi, çünkü ikisi de hata özetinin hiç görünmediği açılış hâline bakıyordu. State'leri taramak, bu katmanın bize kattığı en önemli şey.

Mevcut Borcu Baseline'lamak

Gerçek bir projede kapıyı ilk kurduğunda genellikle onlarca ihlalle karşılaşırsın ve hepsini aynı gün düzeltemezsin. Seçenek iki: kapıyı ihlaller bitene kadar uyarı modunda çalıştırmak ya da mevcut ihlalleri bir baseline'a yazıp yalnız yenilerinde build'i kırmak. İlki kolay, ama uyarılar kısa sürede görmezden gelinir ve borç büyür. İkincisi borcu dondurur: eski sorunlar listede görünür kalır, yeni bir ihlal ise ilk pull request'te durdurulur. Bookstore için ikincisini seçtik. Baseline'ı bilerek güncellemek ayrı bir komut:

A11Y_UPDATE_BASELINE=1 npx playwright test
[
  "cart, error summary | target-size | a[href$=\"#customer-email\"]",
  "cart, error summary | target-size | a[href$=\"#customer-name\"]"
]

Running 6 tests using 1 worker

  ✓  1 tests/a11y.spec.ts:47:3 › catalog: no new axe violations (388ms)
  ✓  2 tests/a11y.spec.ts:47:3 › catalog, suggestions open: no new axe violations (403ms)
  ✓  3 tests/a11y.spec.ts:47:3 › book, details tab: no new axe violations (320ms)
  ✓  4 tests/a11y.spec.ts:47:3 › cart, error summary: no new axe violations (412ms)
  ✓  5 tests/a11y.spec.ts:47:3 › cart, remove dialog: no new axe violations (371ms)
  ✓  6 tests/a11y.spec.ts:47:3 › statement: no new axe violations (240ms)

  6 passed (2.4s)

Dosya repo'da durduğu için her satırı code review'dan geçer; baseline'a satır eklemek, accessibility statement'a bir "known limitation" eklemekle aynı karardır ve ikisi birlikte yapılmalı. Sonra borcu ödedik: link'lere 24 piksel yükseklik verdik.

/* Kendi satırındaki link'ler "inline" hedef sayılmaz: 24px yükseklik ver (WCAG 2.5.8) */
.error-summary a { display: inline-block; min-height: 24px; padding: 0.2rem 0; }

Running 6 tests using 1 worker

  ✓  1 tests/a11y.spec.ts:47:3 › catalog: no new axe violations (403ms)
  ✓  2 tests/a11y.spec.ts:47:3 › catalog, suggestions open: no new axe violations (414ms)
  ✓  3 tests/a11y.spec.ts:47:3 › book, details tab: no new axe violations (322ms)
  ✓  4 tests/a11y.spec.ts:47:3 › cart, error summary: no new axe violations (419ms)
  ✓  5 tests/a11y.spec.ts:47:3 › cart, remove dialog: no new axe violations (375ms)
Fixed, remove from a11y-baseline.json:
  cart, error summary | target-size | a[href$="#customer-email"]
  cart, error summary | target-size | a[href$="#customer-name"]
  ✓  6 tests/a11y.spec.ts:47:3 › statement: no new axe violations (244ms)

  6 passed (2.5s)

Testler geçiyor ve suite düzeltilen satırları adıyla söylüyor. Bu uyarı önemli: düzeltilen bir satır baseline'da kalırsa, aynı ihlal ileride geri geldiğinde kapı onu "bilinen borç" sayar ve sessizce geçirir. Baseline'ı aynı komutla güncelledik; dosya artık boş bir dizi.

Fingerprint'in (ihlali tanımlayan satırın) CSS hedefine dayanması bir zayıflık: Angular'ın ürettiği id'ler ya da sayfadaki sıra değişince aynı ihlal "yeni" görünebilir. Bookstore'da axe'ın seçtiği hedefler (a[href$="#customer-name"] gibi) kararlı; senin projende hedef seçimi kararsızsa satırı kural ve sayfa düzeyinde tutup sayıyı karşılaştırmak daha sağlam olur.

Lighthouse Skoru ve GitHub Actions'ta a11y Kapısı

Lighthouse'un accessibility kategorisi axe-core'un kurallarının bir alt kümesini çalıştırıp ağırlıklı bir skor veriyor; serinin WCAG yazısında ölçtüğümüz gibi kategoride on manuel kontrol de listeleniyor ve skora girmiyor. Bu yüzden skoru bir kanıt olarak değil, regression alarmı olarak kullanıyoruz. Lighthouse raporlarının skorunu yazdıran script'e bir eşik ekledik:

// Lighthouse JSON raporlarının accessibility skorunu ve başarısız audit'lerini yazdırır
// Kullanım: node lh-summary.mjs [--min 100] reports/*.json
// --min verilirse skor altında kaldığında çıkış kodu 1 olur: accessibility kanıtı değil, regression alarmı
import { readFileSync } from 'node:fs';

const args = process.argv.slice(2);
const minAt = args.indexOf('--min');
const min = minAt >= 0 ? Number(args.splice(minAt, 2)[1]) : null;
let below = 0;
for (const file of args) {
  const lhr = JSON.parse(readFileSync(file, 'utf8'));
  const category = lhr.categories.accessibility;
  const failed = category.auditRefs
    .map(ref => lhr.audits[ref.id])
    .filter(a => a.score === 0)
    .map(a => a.id);
  const manual = category.auditRefs.filter(ref => lhr.audits[ref.id].scoreDisplayMode === 'manual').length;
  const score = Math.round(category.score * 100);
  console.log(`${file}: Lighthouse ${lhr.lighthouseVersion} accessibility ${score}`);
  console.log(`  failed: ${failed.join(', ') || 'none'}; manual checks listed: ${manual}`);
  if (min !== null && score < min) below++;
}
if (below) {
  console.log(`${below} report(s) below ${min}`);
  process.exit(1);
}
reports/lh-book.json: Lighthouse 13.6.0 accessibility 100
  failed: none; manual checks listed: 10
reports/lh-catalog.json: Lighthouse 13.6.0 accessibility 100
  failed: none; manual checks listed: 10
reports/lh-statement.json: Lighthouse 13.6.0 accessibility 100
  failed: none; manual checks listed: 10

Hepsini bir GitHub Actions workflow'unda topladık. Sıra ucuzdan pahalıya: lint, component test, Playwright suite'i, Lighthouse, rapor. Playwright mock API'yi kendi webServer ayarıyla başlatıyor; Lighthouse adımı aynı sunucuyu elle açıyor.

# Her pull request için accessibility kapısı: template lint, axe'lı component testleri, sayfa ve state'lerde axe
# (a11y/a11y-baseline.json'da olmayan ihlalde kırılır), regression alarmı olarak Lighthouse skoru, A seviyesi raporu
name: a11y

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  a11y:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: |
            web/bookstore-web/package-lock.json
            a11y/package-lock.json

      - name: Install and build the Angular client
        working-directory: web/bookstore-web
        run: |
          npm ci
          npx ng build

      - name: Template lint (angular-eslint accessibility rules)
        working-directory: web/bookstore-web
        run: npx eslint src

      - name: Component tests (axe in jsdom)
        working-directory: web/bookstore-web
        run: npx ng test --watch=false

      - name: Install the a11y tools and Chromium
        working-directory: a11y
        run: |
          npm ci
          npx playwright install --with-deps chromium

      # Playwright mock API'yi (dist'i de sunar) playwright.config.ts'teki webServer ile başlatır
      - name: axe on pages and states
        working-directory: a11y
        env:
          CI: "true"
        run: npx playwright test

      - name: Lighthouse accessibility score
        working-directory: a11y
        run: |
          (cd .. && node mock-api/server.mjs &)
          for i in $(seq 1 20); do curl -sf http://127.0.0.1:52411/api/categories > /dev/null && break; sleep 1; done
          for page in "catalog /" "book /books/9780441172719" "statement /accessibility"; do
            set -- $page
            npx lighthouse "http://127.0.0.1:52411$2" --only-categories=accessibility --quiet \
              --output=json --output-path="reports/lh-$1.json" --chrome-flags="--headless=new"
          done
          node lh-summary.mjs --min 100 reports/lh-*.json

      - name: Level A report
        if: ${{ !cancelled() }}
        working-directory: a11y
        run: |
          node report.mjs
          cat reports/a11y-report.md >> "$GITHUB_STEP_SUMMARY"

      - uses: actions/upload-artifact@v7
        if: ${{ !cancelled() }}
        with:
          name: a11y-reports
          path: |
            a11y/reports/
            a11y/test-results/

Workflow'u actionlint ile denetledim; hata vermedi (bu makinede shellcheck kurulu olmadığı için run blokları ayrıca shell analizinden geçmedi):

actionlint .github/workflows/a11y.yml
echo $?
0

Sonra repo'yu temiz bir klasöre clone'layıp adımları sırayla yerelde çalıştırdım. Lighthouse adımında macOS'ta tarayıcının Keychain'e erişmemesi için --chrome-flags'e --use-mock-keychain ve geçici bir --user-data-dir ekledim; Ubuntu runner'da bunlara gerek yok:

== npm ci && npx ng build
Application bundle generation complete. [1.298 seconds]
== npx eslint src
exit 0
== npx ng test --watch=false
 Test Files  2 passed (2)
      Tests  2 passed (2)
== npm ci
added 123 packages in 1s
== CI=true npx playwright test
  ✓  6 tests/a11y.spec.ts:47:3 › statement: no new axe violations (237ms)

  6 passed (2.6s)
== lighthouse x3 + lh-summary --min 100
reports/lh-book.json: Lighthouse 13.6.0 accessibility 100
  failed: none; manual checks listed: 10
reports/lh-catalog.json: Lighthouse 13.6.0 accessibility 100
  failed: none; manual checks listed: 10
reports/lh-statement.json: Lighthouse 13.6.0 accessibility 100
  failed: none; manual checks listed: 10
exit 0
== node report.mjs
6 page states, 31 Level A criteria: 0 failed, 1 need review, 8 with automated checks and no issues, 22 manual only

Workflow'un GitHub üzerinde çalıştırılması bu yazıda yapılmadı; GitHub tarafındaki davranış (runner'daki Chrome, artifact yükleme, step summary) bu yüzden doğrulanmadı.

Otomasyonun Sınırları ve Kontrol Listesi için Rapor

Tahminin ikinci sorusu. Deque'nin (axe-core'u geliştiren şirket) Mart 2021'de yayımladığı çalışmaya göre, 2.000'den fazla audit'te ve 13.000'den fazla sayfada bulunan yaklaşık 300.000 sorunun ortalama %57'si otomatik testlerle bulunmuş. Bu bir araç üreticisinin kendi verisi ve sorun sayısına göre ölçüyor: contrast gibi çok sık görülen sorunlar oranı yukarı çekiyor. Başka bir ölçüm GDS'den (Government Digital Service; İngiltere hükümetinin dijital servis birimi): 2017'de 143 bilinçli accessibility engeli içeren bir sayfada on ücretsiz aracı denediler; engellerin %29'unu hiçbir araç bulamadı, en iyi araç %41'de kaldı. Hangi sayıyı alırsan al sonuç aynı: otomasyon gerekli ama yeterli değil.

Bakanlığın kontrol listesi (Aile ve Sosyal Hizmetler Bakanlığı'nın "Web Siteleri ve Mobil Uygulamaların Erişilebilirliği Kontrol Listesi"), serinin ilk yazısında anlattığımız gibi WCAG 2.2'ye dayanan A seviyesi bir liste; izleme sonucunda erişilebilir bulunan siteler Erişilebilirlik Logosu'nu iki yıl kullanabiliyor. Listenin resmî bir rapor biçimi bu yazıda doğrulanamadı; bu yüzden raporumuzu listenin dayandığı birime, WCAG 2.2'nin 31 A seviyesi başarı kriterine göre üretiyoruz. Script, Playwright suite'inin her state için kaydettiği ham axe sonuçlarını kriter kriter topluyor:

// Test koşusunun ham axe sonuçlarını (reports/axe/*.json) WCAG 2.2'nin A seviyesi kriterlerine göre bir rapora çevirir;
// Bakanlık kontrol listesinin seviyesi bu. "automated: no issues" "uygun" demek değil: axe her kriterin yalnız bir kısmına bakar
// Kullanım: node report.mjs   (npx playwright test'ten sonra; reports/a11y-report.md'yi yazar)
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';

// WCAG 2.2'nin 31 A seviyesi başarı kriteri (4.1.1 Parsing kaldırıldı, listede yok)
const levelA = ['1.1.1', '1.2.1', '1.2.2', '1.2.3', '1.3.1', '1.3.2', '1.3.3', '1.4.1', '1.4.2', '2.1.1', '2.1.2', '2.1.4',
  '2.2.1', '2.2.2', '2.3.1', '2.4.1', '2.4.2', '2.4.3', '2.4.4', '2.5.1', '2.5.2', '2.5.3', '2.5.4', '3.1.1', '3.2.1',
  '3.2.2', '3.2.6', '3.3.1', '3.3.2', '3.3.7', '4.1.2'];

// axe kriterleri "wcag111", "wcag1412" diye etiketler: bunları "1.1.1", "1.4.12"ye geri çevir
const criteria = tags => tags.filter(t => /^wcag\d{3,4}$/.test(t)).map(t => `${t[4]}.${t[5]}.${t.slice(6)}`);

const byCriterion = new Map(levelA.map(sc => [sc, { failed: new Set(), passed: new Set(), review: new Set() }]));
const files = readdirSync('reports/axe').filter(f => f.endsWith('.json'));
for (const file of files) {
  const results = JSON.parse(readFileSync(`reports/axe/${file}`, 'utf8'));
  for (const [list, key] of [[results.violations, 'failed'], [results.passes, 'passed'], [results.incomplete, 'review']]) {
    for (const rule of list) {
      for (const sc of criteria(rule.tags)) {
        byCriterion.get(sc)?.[key].add(rule.id);
      }
    }
  }
}

const rows = [...byCriterion].map(([sc, r]) => {
  const status = r.failed.size ? `FAILED (${[...r.failed].join(', ')})`
    : r.review.size ? `needs review (${[...r.review].join(', ')})`
    : r.passed.size ? `automated: no issues (${[...r.passed].join(', ')})`
    : 'manual only';
  return `| ${sc} | ${status} |`;
});
const count = word => rows.filter(row => row.includes(word)).length;
const summary = `${files.length} page states, ${levelA.length} Level A criteria: ${count('FAILED')} failed, ` +
  `${count('needs review')} need review, ${count('automated: no issues')} with automated checks and no issues, ` +
  `${count('manual only')} manual only`;
writeFileSync('reports/a11y-report.md', [
  '# Bookstore accessibility report (WCAG 2.2 Level A)', '', summary, '',
  '| Criterion | Automated result |', '| --- | --- |', ...rows, '',
  'Every criterion still needs the manual checklist and a screen reader pass.', '',
].join('\n'));
console.log(summary);
console.log(rows.filter(row => !row.includes('manual only')).join('\n'));
6 page states, 31 Level A criteria: 0 failed, 1 need review, 8 with automated checks and no issues, 22 manual only
| 1.3.1 | automated: no issues (aria-hidden-body, aria-required-children, aria-required-parent, list, listitem, td-headers-attr, th-has-data-cells) |
| 1.4.1 | automated: no issues (link-in-text-block) |
| 2.4.1 | automated: no issues (bypass) |
| 2.4.2 | automated: no issues (document-title) |
| 2.4.4 | automated: no issues (link-name) |
| 2.5.3 | automated: no issues (label-content-name-mismatch) |
| 3.1.1 | automated: no issues (html-has-lang, html-lang-valid) |
| 3.3.2 | automated: no issues (form-field-multiple-labels) |
| 4.1.2 | needs review (aria-valid-attr-value) |

Bu tabloyu dürüst okumak önemli. 31 kriterin 22'sinde axe'ın hiçbir kuralı yok; bunlar tamamen manuel. Sekizinde axe'ın kuralları sorun bulmadı, ama bu kriterin karşılandığı anlamına gelmiyor: 2.4.4'te axe link'in adı olup olmadığına bakar, adın amacı anlatıp anlatmadığına bakmaz. 4.1.2'deki "needs review", öneri listesi açıkken combobox'ın aria-controls hedefini axe'ın doğrulayamaması; serinin Angular yazısındaki DOM çıktısında id'nin listbox'ta olduğunu gördük, yani bu satır bir insanın baktığı ve not düştüğü bir satır. Rapor workflow'da step summary'ye yazılıyor ve artifact olarak saklanıyor; Bakanlık listesine hazırlanırken her satırın yanına manuel kontrolün sonucunu ve tarihini eklemek gerekiyor.

Otomasyonun yakalayamadığı ve Bookstore'da elle kontrol ettiğimiz şeylerin listesi, serinin yazılarına göre:

  • Alt text'in, link ve buton adlarının anlamı (axe adın varlığına bakar, doğruluğuna değil).
  • Focus sırasının mantığı, route değişiminde ve dialog kapanınca focus'un gittiği yer (serinin keyboard yazısı).
  • Bildirimlerin zamanı, sırası ve gereksiz tekrar (dinamik içerik yazısı); live region'ın sonradan eklenmesi gibi zamanlama hataları statik taramada görünmez.
  • Screen reader'da görevin baştan sona tamamlanabilmesi (screen reader yazısındaki test script'i).
  • Hata mesajlarının ne yapılacağını söylemesi, zaman sınırları, hareketli içerik ve 320 piksel genişlikteki düzen (WCAG yazısındaki audit).

Üretimde Dikkat

Kapıyı önce baseline ile aç, sonra borcu planla. Baseline'daki her satır accessibility statement'ta bir satır olmalı; ikisi ayrıştığında ya beyan yanlış ya da kapı gevşek.

State listesini ürünle birlikte büyüt. Yeni bir dialog, açılır menü ya da hata durumu eklendiğinde Playwright suite'ine o state'i açan bir satır eklemek, pull request'in parçası olsun. Taranmayan state, kapının göremediği state.

Lighthouse eşiğini 100'ün altına çekme ihtiyacı doğarsa nedenini yaz. Skor ağırlıklı olduğu için tek bir kritik ihlal bile skoru birkaç puan düşürebilir; eşiği düşürmek, o ihlali kabul etmek demek.

axe-core'u bilinçli güncelle. Yeni bir sürüm yeni kurallar ya da daha sıkı kontroller getirebilir ve kapı bir anda kırmızıya döner. Sürümü sabitle, güncellemeyi ayrı bir pull request'te yap ve çıkan ihlalleri ya düzelt ya da gerekçesiyle baseline'a yaz.

Periyodik manuel testi takvime bağla. CI her değişikliği kontrol eder, ama screen reader ile ana akış testi ve kontrol listesinin manuel satırları ancak bir insanın zaman ayırmasıyla yapılır; bunu sürüm döngüsüne (ör. her ana sürümden önce) bağlamak işe yarar.

Sık Düşülen Tuzaklar

Birincisi, Lighthouse 100'ü accessibility kanıtı saymak. Bookstore'un serinin başında da 100'ü vardı.

İkincisi, yalnız sayfaların açılış hâlini taramak. Bu yazıdaki tek gerçek ihlal, ancak formu boş gönderince görünen bir state'teydi.

Üçüncüsü, baseline'ı hiç küçültmemek. Düzeltilen satırlar listeden çıkmazsa aynı sorun geri geldiğinde kapı onu görmez.

Dördüncüsü, jsdom'daki axe sonucuna güvenip tarayıcıdaki taramayı atlamak. Contrast, hedef boyutu ve gerçek CSS yalnız tarayıcıda ölçülür.

Beşincisi, otomatik raporu kontrol listesinin cevabı sanmak. "automated: no issues" satırı, insanın bakması gereken bir satırdır.

Küçük Bir Deneme

★☆☆ a11y.spec.ts'teki state listesine boş sepeti ("Your cart is empty") ve sipariş onayını ekle. Yeni bir ihlal çıkıyor mu? report.mjs'in özet satırı değişiyor mu?

★★☆ Fingerprint'i "state | kural | sayı" biçimine çevir ve baseline karşılaştırmasını buna göre yaz. Bir ihlali düzeltip aynı sayfada başka bir yere aynı türden yeni bir ihlal eklediğinde iki yöntem ne söylüyor?

★★★ report.mjs'i WCAG 2.2'nin AA (A'nın üstündeki, yasal düzenlemelerin çoğunun hedeflediği seviye) kriterlerini de kapsayacak şekilde genişlet ve her kriterin yanına "manual: yapıldı / tarih / kim" alanlarını taşıyan bir JSON dosyasından manuel sonuçları ekle. Rapor, accessibility statement sayfasındaki "How we checked" bölümünü besleyebilir mi?

Seri burada bitiyor. Sekiz yazıda Bookstore'u WCAG 2.2'nin yapısından ve Türkiye ile AB'deki (Avrupa Birliği) yükümlülüklerden başlayıp en sık düşülen kriterlere, klavye ve focus yönetimine, ARIA'ya ve accessible name'e, dinamik içeriğe, screen reader testine, Angular'ın hazır accessible component'lerine ve son olarak bu otomasyon kapısına kadar taşıdık. Accessibility bir kere yapılıp biten bir iş değil; her yeni özellik aynı sorularla gelir. Renk paleti ve form tasarımının kararları UI/UX Tasarım İlkeleri serisinde, Playwright'ın kendisi Integration ve E2E Test serisinde ayrıntılı olarak var.