Kot Źródłowy

Nie ufam ci, czyli o testowaniu zdarzeń słów kilka

Hej, dzisiaj znowu kilka miauknięć od pierwszego Lwa polskiego IT! Tym razem przyjrzymy się nieco zdarzeniem DOM i zastanowimy się, na ile można im ufać.

Zdarzenia DOM

Przeglądarka wczytując stronę internetową, zamienia ją na drzewko DOM (Document Object Model – Obiektowy Model Dokumentu). Jak sama nazwa tego tworu wskazuje, każdy element HTML strony jest zamieniany na odpowiadający mu obiekt, z którym następnie możemy wejść w interakcję z poziomu JS.

Niemniej nie tylko przeglądarka i developer wchodzą w interakcję ze stroną, bo dokładnie to samo robi też użytkownik. I właśnie po to, by jakoś pogodzić te wszystkie interakcje i pozwolić wykryć, kiedy coś się na stronie dzieje i czy jest to wina użytkownika, powstały zdarzenia DOM. Zdarzeń jest mnóstwo i niemal wszystko, co może się na stronie stać, ma odpowiednie zdarzenie. Ktoś przewinął stronę? Zachodzi zdarzenie scroll. Strona się wczytała? No to mamy do czynienia z load. Animacja w jakimś komponencie się zakończyła? animationend. Ba, samo drukowanie ma co najmniej dwa zdarzenia: beforeprint i afterprint – no bo po co się ograniczać! A jak jeszcze komuś mało, to można tworzyć własne zdarzenia.

Testowanie zachowania użytkownika

Wyobraźmy sobie, że dostaliśmy zadanie przeprowadzenia prostego audytu dostępności i chcemy sobie zautomatyzować nieco pracę. Jednym z punktów na liście jest sprawdzenie, czy na stronie jest odpowiednia kolejność focusu, czyli czy do interaktywnych elementów strony (przycisków, linków itp.) da się dostać z poziomu klawiatury i czy przy naciśnięciu klawisza Tab są one podświetlane w tej kolejności, w której występują w kodzie. Wyobraźmy sobie, że mamy taką prostą stronę:

<!DOCTYPE html>
	<html lang="pl">
		<head>
			<meta charset="UTF-8">
			<title>Prosta strona</title>
		</head>
		<body>
			<button>Przycisk</button>
			<a href="https://kot-zrodlowy.pl">Koteł</a>
			<a href="https://www.comandeer.pl">Leweł</a>
			<button>Drugi przycisk</button>
		</body>
	</html>

W tym wypadku po naciśnięciu Tab najpierw powinien się podświetlić przycisk, następnie linki, a na samym końcu drugi przycisk.

Zastanówmy się, jakby to można było zautomatyzować! Na pewno musielibyśmy znać poprawną, oczekiwaną kolejność podświetlania elementów. To akurat proste, bo ma być taka sama, jak kolejność elementów w kodzie. Wystarczy zatem pobrać wszystkie interaktywne elementy, a ich kolejność w tak powstałej kolekcji będzie równocześnie poprawną kolejnością podświetlania:

const elements = document.querySelectorAll( 'button, a' );

Podświetlanie kolejnych elementów odbywa się w czasie naciśnięcia klawisza Tab. A naciśnięcie klawisza to jakaś akcja ze strony użytkownika, więc na pewno jest do tego przypisane jakieś zdarzenie! W tym wypadku jest to keydown. Żeby móc reagować na to zdarzenie, trzeba przypiąć do niego “nasłuchiwacza” (ang. listener) przy pomocy addEventListener:

document.addEventListener( 'keydown', ( evt ) => {
	alert( `Naciśnięty klawisz: ${ evt.key }` );
} );

Jeśli teraz przetestujemy to na naszej małej stronie, zauważymy, że naciśnięcie jakiegokolwiek klawisza spowoduje wyświetlenie odpowiedniego komunikatu, jaki klawisz został naciśnięty:

Jak widać, klawisz pobieramy z właściwości evt.key. W naszym przypadku chcemy reagować wyłącznie na klawisz Tab oraz sprawdzać, jaki element jest obecnie podświetlony. Tę drugą informację dostarczy nam właściwość document.activeElement. Przerzucimy się też na konsolę zamiast tradycyjnego alert:

document.addEventListener( 'keydown', ( evt ) => {
	if ( evt.key === 'Tab' ) {
		console.log( 'Podświetlony element', document.activeElement );
	}
} );

Przetestujmy:

Jak widać, coś nie do końca działa – nasz komunikat jest jakby przesunięty o jeden element do tyłu. Problemem w tym wypadku jest fakt, że używamy zdarzenia keydown. Zachodzi ono w momencie, gdy użytkownik wciska klawisz, ale przed odpaleniem domyślnego zachowania przeglądarki dla danego klawisza. Mówiąc inaczej: można sobie wyobrazić, że najpierw są odpalane wszystkie nasłuchiwacze stworzone przez programistę, a dopiero na końcu – domyślny nasłuchiwacz przeglądarki. Dzięki temu programista może zablokować domyślne zachowanie przeglądarki. Musimy sobie znaleźć zatem inne zdarzenie. Idealnym wydaje się keyup, które odpala się w momencie, w którym użytkownik zdejmuje palec z klawisza (czyli de facto w momencie wykonania pełnego naciśnięcia). Zmieńmy zatem nasz kod z keydown na keyup i przetestujmy:

document.addEventListener( 'keyup', ( evt ) => {
	if ( evt.key === 'Tab' ) {
		console.log( 'Podświetlony element', document.activeElement );
	}
} );

Działa!

Zastanówmy się zatem, jak sprawdzić, czy kolejność jest prawidłowa. Wystarczy nacisnąć Tab tyle razy, ile mamy elementów w kolekcji i każdy podświetlony element dodać do nowej tablicy. Na samym końcu – gdy obydwie tablice będą miały tyle samo elementów – wystarczy porównać, czy elementy z tej tablicy są w takiej samej kolejności, jak elementy w kolekcji. Napiszmy to zatem:

const elements = document.querySelectorAll( 'button, a' );
const focused = [];

function compareElements( col1, col2 ) { // 3
	return col1.every( ( element, i ) => { // 4
		return element === col2[ i ]; // 5
	} );
}

document.addEventListener( 'keyup', ( evt ) => {
	if ( evt.key === 'Tab' ) {
		console.log( 'Podświetlony element', document.activeElement );
		focused.push( document.activeElement ); // 1
		
		if ( focused.length === elements.length ) { // 2
			console.log( compareElements( focused, elements ) ); // 6
		}
	}
} );

Każdy podświetlony element wkładamy do tablicy focused (1). Gdy osiągnie ona taką samą długość, jak kolekcja elements (2), wtedy porównujemy obydwie kolekcje używając funkcji compareElements (3). Jako parametry przyjmuje ona obydwie kolekcje, jakie chcemy porównać. W jej wnętrzu wykorzystujemy [].every (4; zauważmy, że metoda ta jest dostępna tylko dla tablicy focused, podczas gdy elements nie jest tablicą a kolekcją NodeList). Funkcja ta zwróci true tylko wtedy, gdy dla każdego elementu tablicy warunek z wywołania zwrotnego (5) będzie prawdziwy. A warunek ten sprawdza, czy element znajdujący się w focused pod indeksem i jest tym samym elementem, który znajduje się w kolekcji elements pod indeksem i. Ostateczny wynik zostanie wyrzucony do konsoli (6).

Przetestujmy to zatem:

Automatyzacja testowania

No dobrze, ale nie napisaliśmy całego tego kodu, by i tak sami naciskać Tab, prawda? Na szczęście przeglądarki udostępniają też możliwość sztucznego wywoływania zdarzeń. W naszym wypadku tym zdarzeniem jest keyup. Spróbujmy je zatem wywołać tyle razy, ile elementów mamy, symulując tym samym naciśnięcie Tab:

elements.forEach( () => { // 1
	const keyupEvent = new KeyboardEvent( 'keyup', { // 2
		key: 'Tab' // 3
	} );
	
	document.dispatchEvent( keyupEvent ); // 4
} );

Dla każdego elementu w kolekcji elements (1) tworzymy nowe zdarzenie (2). Jak widać, jest to specyficzny rodzaj zdarzenia – KeyboardEvent. Przekazujemy mu informację, jaki klawisz ma być symulowany (3). Ostatecznie wyzwalamy to zdarzenie na dokumencie (4).

Sprawdźmy, jak to działa:

Otóż nie działa. Co prawda nasz nasłuchiwacz się odpala, ale za każdym razem podświetlony jest ten sam element – body, czyli de facto cała strona. Czyżbyśmy coś ominęli? Może brakuje pełnej symulacj naciskania klawisza (czyli odpalenia zarówno keydown i keyup)? Niestety nie, chodzi o coś zupełnie innego…

“Nie ufam ci!”

W DOM istnieje koncept zaufanych zdarzeń:

Events that are generated by the user agent, either as a result of user interaction, or as a direct result of changes to the DOM, are trusted by the user agent with privileges that are not afforded to events generated by script […].

Most untrusted events will not trigger default actions, with the exception of the click event.

Zdarzenia, które są generowane przez agenta użytkownika [przeglądarkę], zarówno jako rezultat interakcji ze strony użytkownika, jak i bezpośredni rezultat zmian w DOM, są zdarzeniami zaufanymi przez przeglądarkę i posiadają przywileje, których nie posiadają zdarzenia generowane przez skrypt […].

Większość niezaufanych zdarzeń nie odpala domyślnych akcji, z wyjątkiem zdarzenia click.]

Jak widać, przeglądarki mają mechanizm pozwalający na odróżnianie zdarzeń wywołanych bezpośrednio przez użytkownika a zdarzeń wywołanych sztucznie, przy pomocy skryptów. W kodzie można to wykryć przy pomocy właściwości evt.isTrusted.

Czy da się zatem automatycznie testować strony przy równoczesnym symulowaniu zachowań użytkownika? Tak, ale niekoniecznie z poziomu przeglądarki. Służą do tego odpowiednie narzędzia i standardy, takie jak WebDriver, Selenium czy Puppeteer. Ale to już temat na inną historię, a dzisiaj się już śpieszę do dżungli. Roar!


Autorem tego wpisu jest Tomasz ‘Comandeer’ Jakut. Link do jego bloga