diff --git a/.gitignore b/.gitignore index c439cf649..c65c0c569 100644 --- a/.gitignore +++ b/.gitignore @@ -226,6 +226,9 @@ TODO-*.md # Rollbar.js kopiowany z node_modules przez grunt (build artifact, nie commitować) src/bpp/static/rollbar/ +# axe-core kopiowany z node_modules przez grunt (build artifact, nie commitować) +src/bpp/static/axe/ + # SDD workflow scratch (briefs, reports, ledger, diffs) .superpowers/ diff --git a/Gruntfile.js b/Gruntfile.js index d0db4c9f2..6089bd380 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -291,6 +291,15 @@ module.exports = function (grunt) { 'cp node_modules/rollbar/dist/rollbar.umd.min.js ' + 'src/bpp/static/rollbar/rollbar.umd.min.js' }, + copyAxe: { + // axe-core dla bramki dostepnosci. Kopiujemy do statykow tym + // samym wzorcem co Rollbara, bo obraz CI `test-runner` NIE ma + // node_modules — wstrzykiwanie prosto stamtad dziala lokalnie + // i pada na CI. + command: 'mkdir -p src/bpp/static/axe && ' + + 'cp node_modules/axe-core/axe.min.js ' + + 'src/bpp/static/axe/axe.min.js' + }, collectstatic: { command: 'uv run src/manage.py collectstatic --noinput -v0 --traceback' } @@ -331,12 +340,20 @@ module.exports = function (grunt) { 'shell:esbuildThree', 'shell:patchBundle', 'shell:copyRollbar', + 'shell:copyAxe', 'shell:collectstatic', 'stampBuild' ]); // `build-non-interactive` CELOWO nie dotyka sentinela: pomija // `collectstatic`, wiec nie spelnia tego, co `make assets` obiecuje. // Uzywaja go tylko Dockerfile'e, gdzie `make` nie wystepuje. + // + // `shell:copyAxe` MUSI tu byc: to jedyna lista tasków, ktora faktycznie + // biegnie w stage'u `test-assets-builder` obrazu CI (`docker/bpp_base/ + // Dockerfile`, `RUN npx grunt build-non-interactive`) — `build` (z + // `collectstatic`) tam nigdy sie nie wykonuje. Bez tego wpisu axe.min.js + // istnialby lokalnie (przez `make assets` -> `build`), ale nie trafilby + // do obrazu test-runnera. grunt.registerTask('build-non-interactive', [ 'concurrent:themes', 'shell:linkSitePackages', @@ -344,7 +361,8 @@ module.exports = function (grunt) { 'shell:esbuildCytoscape', 'shell:esbuildThree', 'shell:patchBundle', - 'shell:copyRollbar' + 'shell:copyRollbar', + 'shell:copyAxe' ]); // Rename the original watch task and create an alias that builds first diff --git a/Makefile b/Makefile index cfe979310..77deefeff 100644 --- a/Makefile +++ b/Makefile @@ -290,6 +290,10 @@ production-assets: distclean assets ## Pełny clean + build assetów pod produkc # usuń ze staticroot niepotrzebne pakiety (Poetry pyproject.toml exclude # nie do końca to załatwia...) rm -rf src/django_bpp/staticroot/{qunit,sinon} +# axe-core (`shell:copyAxe`) to biblioteka testowa dla bramki axe +# (src/integration_tests/test_wcag_bramka_axe.py) — 580 KB, którego nic +# na produkcji nie ładuje. Ten sam wzorzec jak qunit/sinon wyżej. + rm -rf src/django_bpp/staticroot/axe rm -rf src/django_bpp/staticroot/sitemap-* rm -rf src/django_bpp/staticroot/grappelli/tinymce/ rm -rf src/django_bpp/staticroot/autocomplete_light/vendor/select2/tests/ diff --git a/docker/bpp_base/Dockerfile b/docker/bpp_base/Dockerfile index 7c0117c86..7a338c9ac 100644 --- a/docker/bpp_base/Dockerfile +++ b/docker/bpp_base/Dockerfile @@ -168,11 +168,16 @@ RUN /app/.venv/bin/python src/manage.py compilemessages -v0 -l pl --traceback # Source maps sluza tylko browser devtools podczas debugowania JS/CSS; # w prod 404 na .map pojawia sie w konsoli gdy user otworzy devtools — # bez wplywu na dzialanie aplikacji. +# R17: --ignore axe wycina axe-core (`shell:copyAxe` w Gruntfile.js, ~580 KB +# axe.min.js), skopiowany do statykow WYLACZNIE dla bramki dostepnosci Playwright +# (src/integration_tests/test_wcag_bramka_axe.py) i przez `build-non-interactive` +# trafiajacy takze do tego stage'a. Nic na produkcji go nie laduje — sam wzorzec +# co sinon/qunit powyzej. RUN DJANGO_BPP_SECRET_KEY=build-time-only-not-used \ STATIC_ROOT=/app/staticroot.baked \ /app/.venv/bin/python src/manage.py collectstatic \ --noinput -v0 --traceback \ - --ignore sinon --ignore qunit --ignore '*.map' + --ignore sinon --ignore qunit --ignore axe --ignore '*.map' # R16: prekompresja gzip dla `gzip_static on` po stronie nginksa (bpp-deploy, # defaults/webserver/_bpp-locations.conf). Ta dyrektywa jest tam wlaczona od diff --git a/docs/superpowers/plans/2026-08-25-wcag-faza3-bramka-axe.md b/docs/superpowers/plans/2026-08-25-wcag-faza3-bramka-axe.md new file mode 100644 index 000000000..2f617de58 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-wcag-faza3-bramka-axe.md @@ -0,0 +1,1036 @@ +# WCAG faza 3 — bramka axe-core: plan wdrożenia + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Naprawić 14 naruszeń WCAG wykrytych przez axe-core na trzech +publicznych stronach BPP i postawić bramkę, która nie przepuści kolejnych. + +**Architecture:** Bramka to moduł testowy Playwright w istniejącym harnessie +(`src/integration_tests/`). `axe.min.js` trafia do statyków przez zadanie +grunta — tym samym wzorcem, którym repo kopiuje już Rollbara — więc jest +dostępny i lokalnie, i w obrazie CI `test-runner`, który nie ma +`node_modules`. Próg: zero naruszeń, bez pliku baseline. + +**Tech Stack:** axe-core 4.13 (devDependency przez yarn), Playwright +(pytest-playwright), Grunt, SCSS/Dart Sass, Django templates. + +Spec: `docs/superpowers/specs/2026-08-17-wcag-faza3-bramka-axe-design.md` + +## Global Constraints + +- Python: max 88 znaków w linii (ruff). +- Testy: wyłącznie konwencja pytest — funkcje, nie klasy `unittest.TestCase`. +- Komentarze w szablonach Django: `{# ... #}` **jednoliniowe**, każda linia + z własnym otwarciem i zamknięciem. +- Nigdy `npm install` — projekt używa Yarn. +- Nigdy `pre-commit --all-files` ani `ruff check --fix`. +- Nigdy `make clean-testcontainers` — na hoście biegają cudze kontenery. +- Po zmianie SCSS: `make assets` (nie `npx grunt build` — choć od #772 grunt + też stempluje sentinel, `make assets` jest kanoniczne). +- Do werdyktu „zielono" NIE używać `PYTEST_TESTCONTAINERS_REUSE=1` — + współdzielona baza daje fałszywe wyniki w testach zależnych od stanu. +- Host bywa obciążony: `-n 4`, nie `-n auto`. +- Każdy commit kończy się stopką `Co-Authored-By: Claude Opus 5 (1M context) + `. + +## Kolory — wartości docelowe (policzone, nie oszacowane) + +| było | będzie | kontrast po zmianie | +|---|---|---| +| `#7f8c8d` | `#68706f` | 5.08 na `#ffffff`, 5.03 na `#fefefe`, 4.82 na `#f8f9fa` | +| `#6c757d` | `#636b73` | 5.41 na `#ffffff`, 5.13 na `#f8f9fa` | +| `green` (`#008000`) | `#006e00` | 5.65 na `#efefef` | + +Wszystkie z zapasem ponad próg 4.5, żeby drobna zmiana tła nie wywróciła +bramki. + +## Struktura plików + +| plik | odpowiedzialność | +|---|---| +| `Gruntfile.js` | nowe zadanie `shell:copyAxe` w liście `build` | +| `.gitignore` | `src/bpp/static/axe/` jako artefakt builda | +| `package.json`, `yarn.lock` | `axe-core` jako devDependency | +| `src/integration_tests/axe_helper.py` | jedyne miejsce z konfiguracją axe: tagi, wstrzyknięcie, formatowanie raportu | +| `src/integration_tests/test_wcag_bramka_axe.py` | bramka: trzy strony, próg zero, sanity | +| `src/bpp/templates/browse/{autor,jednostka,zrodlo,tytul_raportu}.html` | etykiety pól `suggested-title` | +| `src/bpp/tests/test_wcag/test_etykiety_pol.py` | semantyczne testy dostępnej nazwy | +| `src/bpp/static/scss/*.scss` | podmiana dwóch szarości | +| `src/bpp/static/scss/app-green.scss` | punktowy override zieleni breadcrumbs | + +--- + +### Task 1: axe-core dostępny lokalnie i w obrazie CI + +Bez tego wszystko dalej jest bezużyteczne: obraz `test-runner`, w którym CI +uruchamia testy, **nie zawiera `node_modules`** (stage `test-assets-builder` +kompiluje assety osobno, a do finalnego obrazu trafiają „tylko gotowe pliki +z /src/src — bez Node, Yarn, Grunta i node_modules"). Wstrzykiwanie prosto +z `node_modules` przeszłoby lokalnie i padło na CI. + +Rozwiązanie kopiuje wzorzec, który repo już stosuje dla Rollbara +(`Gruntfile.js`, zadanie `shell:copyRollbar`): grunt przenosi plik z +`node_modules` do katalogu statycznego pod `src/`, a ten katalog wchodzi do +obrazu razem z resztą źródeł. + +**Files:** +- Modify: `package.json`, `yarn.lock` (przez `yarn add`) +- Modify: `Gruntfile.js` (zadanie `copyAxe` + wpis w `build`) +- Modify: `.gitignore` +- Create: `src/integration_tests/axe_helper.py` + +**Interfaces:** +- Consumes: nic +- Produces: `axe_helper.skanuj(page) -> dict` — zwraca surowy wynik + `axe.run()` (klucze `violations`, `passes`, `incomplete`, `inapplicable`); + `axe_helper.opisz_naruszenia(wynik) -> str` — czytelny raport; + `axe_helper.SCIEZKA_AXE: pathlib.Path`. + +- [ ] **Step 1: Dodaj axe-core jako devDependency** + +```bash +cd /Volumes/SSD/Programowanie/bpp-wcag-faza2 +yarn add -D axe-core +``` + +Sprawdź, że `package.json` ma teraz `axe-core` w `devDependencies`, a +`yarn.lock` się zmienił. + +- [ ] **Step 2: Dodaj zadanie kopiujące do Gruntfile** + +W `Gruntfile.js`, w sekcji `shell:`, tuż po zadaniu `copyRollbar` (ok. linii +293) dopisz: + +```js + copyAxe: { + // axe-core dla bramki dostepnosci. Kopiujemy do statykow tym + // samym wzorcem co Rollbara, bo obraz CI `test-runner` NIE ma + // node_modules — wstrzykiwanie prosto stamtad dziala lokalnie + // i pada na CI. + command: 'mkdir -p src/bpp/static/axe && ' + + 'cp node_modules/axe-core/axe.min.js ' + + 'src/bpp/static/axe/axe.min.js' + }, +``` + +- [ ] **Step 3: Wpisz zadanie do listy `build`** + +W `grunt.registerTask('build', [...])` dopisz `'shell:copyAxe'` bezpośrednio +po `'shell:copyRollbar'`, a **przed** `'shell:collectstatic'` — inaczej plik +nie zdąży trafić do staticroot. + +- [ ] **Step 4: Zignoruj katalog artefaktu** + +W `.gitignore`, tuż pod wpisem `src/bpp/static/rollbar/` (ok. linii 227): + +``` +# axe-core kopiowany z node_modules przez grunt (build artifact, nie commitować) +src/bpp/static/axe/ +``` + +- [ ] **Step 5: Zbuduj i sprawdź, że plik jest** + +```bash +make assets +ls -la src/bpp/static/axe/axe.min.js +``` + +Oczekiwane: plik istnieje, ok. 580 KB. + +- [ ] **Step 6: Napisz helper** + +Utwórz `src/integration_tests/axe_helper.py`: + +```python +"""Wspólna konfiguracja axe-core dla bramki dostępności (WCAG faza 3). + +Jedno miejsce z tagami i wstrzykiwaniem, żeby dało się je zmienić raz, +a nie w trzech testach — i żeby przegląd kodu widział każdą zmianę +konfiguracji bramki w jednym pliku. + +`axe.min.js` bierzemy ze statyków, nie z `node_modules`: obraz CI +`test-runner` nie zawiera zależności Node, więc ścieżka przez +`node_modules` działa lokalnie i pada na CI. Plik kopiuje tam zadanie +`shell:copyAxe` z `Gruntfile.js`. +""" + +import json +from pathlib import Path + +SCIEZKA_AXE = ( + Path(__file__).resolve().parents[1] + / "bpp" + / "static" + / "axe" + / "axe.min.js" +) + +# Zakres audytu: WCAG 2.2, poziomy A i AA. `wcag22a` zostaje celowo, mimo +# że dziś nie niesie regul — jako zabezpieczenie na wypadek dodania ich +# w przyszlych wersjach axe (ustalenie ze specyfikacji 2026-08-05). +TAGI = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"] + + +def skanuj(page): + """Wstrzykuje axe i zwraca surowy wynik `axe.run()`.""" + if not SCIEZKA_AXE.exists(): + raise AssertionError( + f"Brak {SCIEZKA_AXE}. Uruchom `make assets` — plik kopiuje " + "zadanie shell:copyAxe z Gruntfile.js." + ) + page.add_script_tag(path=str(SCIEZKA_AXE)) + return page.evaluate( + "async () => await axe.run(document, {runOnly: {type: 'tag', " + "values: " + json.dumps(TAGI) + "}})" + ) + + +def opisz_naruszenia(wynik): + """Czytelny raport: reguła, waga, selektor i fragment HTML-a. + + Diagnoza ma nie wymagać powtarzania pomiaru — komunikat z CI musi + wystarczyć, żeby wiedzieć, co poprawić. + """ + linie = [] + for v in wynik["violations"]: + linie.append(f"\n{v['id']} [{v['impact']}] — {len(v['nodes'])} elem.") + for n in v["nodes"]: + selektor = n["target"][0] if n["target"] else "?" + html = " ".join(n["html"].split())[:120] + linie.append(f" sel: {selektor}") + linie.append(f" html: {html}") + return "\n".join(linie) + + +def opisz_niejednoznaczne(wynik): + """Raport `incomplete` — reguł, których axe nie umiał rozstrzygnąć. + + Nie blokują bramki, ale muszą być widoczne: naruszenie potrafi + zmigrować z `violations` do `incomplete` (np. po zmianie tła na + półprzezroczyste) i cicho zniknąć z pola widzenia. + """ + if not wynik.get("incomplete"): + return "incomplete: brak" + czesci = [ + f"{v['id']}({len(v['nodes'])})" for v in wynik["incomplete"] + ] + return "incomplete: " + ", ".join(czesci) +``` + +- [ ] **Step 7: Napisz test, że helper w ogóle działa** + +Utwórz `src/integration_tests/test_wcag_bramka_axe.py`: + +```python +"""Bramka dostępności: axe-core na publicznych stronach BPP (WCAG faza 3). + +Próg to ZERO naruszeń, bez pliku baseline — przy czternastu naprawianych +naruszeniach zapadka byłaby droższa niż sama naprawa. + +WYMAGANIE WSTĘPNE: `make assets`. Bez niego nie ma `axe.min.js` w statykach +ani zbudowanego CSS, więc pomiar kontrastu byłby bez sensu. +""" + +import pytest +from django.urls import reverse +from model_bakery import baker +from playwright.sync_api import Page + +from bpp.models import Autor +from integration_tests import axe_helper + + +@pytest.mark.django_db(transaction=True) +def test_axe_daje_sie_uruchomic(channels_live_server, page: Page, transactional_db): + """Sanity dla samego harnessu: axe się wstrzykuje i coś ocenia. + + Osobny test od bramki, bo odpowiada na inne pytanie. Bramka mówi „brak + naruszeń"; ten mówi „pomiar w ogóle się odbył". Bez niego zielona bramka + mogłaby znaczyć, że axe się nie załadował. + """ + autor = baker.make(Autor, imiona="Jan", nazwisko="Kowalski", pokazuj=True) + page.goto( + f"{channels_live_server.url}" + f"{reverse('bpp:browse_autor', args=[autor.slug])}", + wait_until="networkidle", + ) + + wynik = axe_helper.skanuj(page) + + ocenione = len(wynik["violations"]) + len(wynik["passes"]) + assert ocenione > 10, ( + f"axe ocenił tylko {ocenione} reguł — wygląda, jakby się nie " + "uruchomił albo trafił na pustą stronę" + ) +``` + +- [ ] **Step 8: Uruchom test** + +```bash +BPP_SKIP_ASSETS_BUILD=1 uv run pytest \ + src/integration_tests/test_wcag_bramka_axe.py -q +``` + +Oczekiwane: PASS. + +- [ ] **Step 9: Sprawdź, że plik jest też w obrazie CI** + +To jedyny sposób, żeby wiedzieć, że Task 1 zrobił, co obiecał — lokalna +suita niczego tu nie dowodzi, bo lokalnie `node_modules` istnieje. + +```bash +docker build -f docker/bpp_base/Dockerfile --target test-runner -t bpp-test-runner-sprawdzenie . +docker run --rm bpp-test-runner-sprawdzenie ls -la /src/src/bpp/static/axe/axe.min.js +``` + +Oczekiwane: plik istnieje w obrazie. Jeśli nie — `shell:copyAxe` biegnie po +`collectstatic` albo w złym stage'u; popraw kolejność w `build`. + +- [ ] **Step 10: Commit** + +```bash +git add package.json yarn.lock Gruntfile.js .gitignore \ + src/integration_tests/axe_helper.py \ + src/integration_tests/test_wcag_bramka_axe.py +git commit -m "$(cat <<'MSG' +feat(wcag): axe-core dostepny lokalnie i w obrazie CI + +Obraz `test-runner`, w ktorym CI uruchamia testy, NIE zawiera +node_modules — assety kompiluje osobny stage, a do finalnego obrazu trafiaja +tylko gotowe pliki. Wstrzykiwanie axe prosto z node_modules przeszloby +lokalnie i padlo na CI. + +Grunt kopiuje wiec axe.min.js do statykow tym samym wzorcem, ktorym repo +kopiuje juz Rollbara (shell:copyRollbar). Katalog jest gitignorowany jako +artefakt builda. + +Helper trzyma tagi i wstrzykiwanie w jednym miejscu, zeby zmiana +konfiguracji bramki byla widoczna w przegladzie kodu jako jeden plik. + +Co-Authored-By: Claude Opus 5 (1M context) +MSG +)" +``` + +--- + +### Task 2: Etykiety dla widocznych pól `suggested-title` + +axe zgłosił `label` (critical) na stronie autora. Ten sam nieopisany +`input[type="text"][name="suggested-title"]` jest w czterech szablonach — +bramka zobaczy tylko jeden, więc pozostałe trzy dostają test szablonowy. + +Warianty `type="hidden"` tego samego pola (m.in. `uczelnia.html:187`) są +poprawne: ukryte pola nie wymagają etykiety. Nie ruszaj ich. + +**Files:** +- Modify: `src/bpp/templates/browse/autor.html:349` +- Modify: `src/bpp/templates/browse/jednostka.html:524` +- Modify: `src/bpp/templates/browse/zrodlo.html:118` +- Modify: `src/bpp/templates/browse/tytul_raportu.html:5` +- Create: `src/bpp/tests/test_wcag/test_etykiety_pol.py` + +**Interfaces:** +- Consumes: nic +- Produces: nic + +- [ ] **Step 1: Ustal, czy `tytul_raportu.html` żyje** + +```bash +grep -rn "tytul_raportu" src/ --include="*.py" --include="*.html" | grep -v "^src/bpp/templates/browse/tytul_raportu.html" +``` + +Jeśli wynik jest pusty, szablon nie jest nigdzie renderowany — wtedy zamiast +go poprawiać, **skasuj go** (`git rm`) i pomiń dalsze kroki jego dotyczące. +Faza 1 usunęła już jeden taki martwy szablon. Zapisz decyzję w komunikacie +commita. + +- [ ] **Step 2: Napisz failujący test semantyczny** + +Test sprawdza **dostępną nazwę** na wyrenderowanym DOM, nie obecność +znacznika w źródle. `