👨‍💻 Kod / Programowanie

Lokalizacja elementów z viewport i bounding box w Playwright

Language
date
Dec 9, 2023
slug
playwright-viewport-elements
author
status
Public
tags
Playwright
TypeScript
Porady
Automatyzacja
summary
Jak pobrać i sprawdzić wszystkie elementy z viewport?
type
Post
thumbnail
playwright-viewport-elements.jpg
updatedAt
Feb 12, 2024 01:23 PM
category
👨‍💻 Kod / Programowanie
W poprzednim wpisie poznaliśmy czym jest viewport i jak z niego korzystamy w Playwright: 🔗 https://playwright.info/playwright-viewport
W tym wpisie poznasz kreatywne wykorzystanie tej wiedzy i uzupełnienie jej o nowe obszary😉
 

Problem: w jaki sposób skorzystać z viewport bez asercji?

 
Już wiemy, że łatwo sprawdzimy elementy, poprzez asercję:
await expect(browsersImage).toBeInViewport();
Ale jak samodzielnie sprawdzić, np. dla listy elementów, które znajdują się w widoku, aby potem działać właśnie na takich elementach?
 
Załóżmy, że chcemy pobrać wszystkie obrazki widoczne na stronie:
Widoczne oczywiście w viewport😉
 

Pierwszy krok - zbieramy lokatory

 
Na początek pobierzmy lokatory do wszystkich obrazków ze strony (zwróć uwagę na sprytne wykorzystanie all()):
 
test("check if found images are in viewport", async ({page}) => { await page.goto('https://playwright.dev/'); const images = await page.getByRole('img').all(); console.log('Found images:', images.length); });
Wynik: Found images: 13
Sporo tych obrazków, a my chcemy tylko te w pełni widoczne w viewport.
 

Drugi krok: przechodzimy po lokatorach

 
Mamy listę lokatorów w obiekcie images. Stwórzmy funkcję, która umożliwi nam wykonanie operacji na poszczególnych lokatorach:
const getElementsFormViewport = async (elements: Locator[]) => { for (const element of elements) { } };
Ta funkcja przyjmuje listę lokatorów elements: Locator[]. A docelowo ma nam zwrócić listę elementów znajdujących się w viewport.
 
Dodajmy pustą listę elementsInViewport, którą uzupełnimy w pętli a następnie ją zwrócimy:
const getElementsFormViewport = async (elements: Locator[]): Promise<Locator[]> => { const elementsInViewport: Locator[] = []; for (const element of elements) { elementsInViewport.push(element); } return elementsInViewport; };
Zauważ, że funkcja zwraca Promise<Locator[]> , czyli promise. Jest to związane z tym, że używamy słowa kluczowego async w deklaracji funkcji. Dzięki temu promise zostanie rozwiązany przy użyciu tej funkcji z operatorem await. I dostaniemy tym samym czystą listę lokatorów Locator[].
 
W obecnym kształcie nasz kod przerzuca wszystkie lokatory z jednej listy do drugiej, a nie o to nam chodzi😁
Czas dodać funkcję filtrującą, której użyjemy w pętli. Zasymulujmy jej użycie poprzez weryfikację elementów przez jeszcze nieistniejącą funkcję await isInViewport(element):
 
const getElementsFormViewport = async (elements: Locator[]): Promise<Locator[]> => { const elementsInViewport: Locator[] = []; for (const element of elements) { if (await isInViewport(element)) { elementsInViewport.push(element); } } return elementsInViewport; };
 
Jeśli await isInViewport(element) zakończy się powodzeniem możemy dodać taki element do naszej listy elementsInViewport.
 

Funkcja weryfikacji elementu

Zróbmy prototyp dla użycia funkcji await isInViewport(element)
const isInViewport = async (element: Locator): Promise<boolean> => { };
Jak widzimy funkcja przyjmuje dany lokator element: Locator i zwróci true albo false (Promise<boolean>).
Chcemy, aby to się stało w zależności od tego czy dany element znajduje się w viewport. Zacznijmy więc ją uzupełniać!
 
Na początek pobierzemy sobie obecny viewport strony. Odniesiemy się do strony w sprytny sposób, korzystając z wywołania page poprzez element
const viewportSize = element.page().viewportSize();
Przypomnijmy jak wygląda wartość vieport : { width: 1280, height: 720 }
Ok skoro wiemy w jakich granicach ma być element, musimy pobrać współrzędne i wielkość elementu aby dowiedzieć się czy znajduje się w obszarze vieport:
notion image
 
Zrealizujemy to za pomocą funkcji boundingBox()

Wyjaśnienie boundingBox

Odskoczmy na chwilę od naszych funkcji i przyjrzyjmy się bliżej boundingBox().
Zobaczmy jak będzie wyglądał boundingBox() dla obrazka z logo przeglądarek:
notion image
test("browsers image bounding box", async ({page}) => { await page.goto('https://playwright.dev/'); const browsersImage = page.getByAltText('Browsers'); console.log(await browsersImage.boundingBox()); });
Wynik:
{ x: 384, y: 549.171875, width: 512, height: 125.203125 }
Na grafice poniżej wyjaśniam współrzędne, początek miary znajduje się w lewym górnym rogu:
notion image
  • x: dotyczy szerokości od początku widoku do pierwszego piksela elementu w osi poziomej
  • y: od początku widoku do pierwszego piksela elementu w osi pionowej
  • w: szerokość elementu
  • h: wysokość elementu
 
Znając te wartości możemy śmiało obliczyć czy x (odległość od lewej) + w (szerokość) oraz y (odległość od góry) + h (wysokość) nie wychodzą poza wartości viewport:
x + w <= viewport.x 896 <= 1280
y + h <= viewport.y 674 <= 720
Z obliczeń jasno wynika, że sprawdzany element jako całość znajduje się w viewport. Jeśli chociaż jeden piksel naszego elementu wychodzi po za viewport wtedy nie będzie on brany pod uwagę.

Sprawdzenie boundingBox() w funkcji isInViewport

 
Dodajemy więc pobranie boundingBox() do naszej funkcji:
const isInViewport = async (element: Locator): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); };
Warto upewnić się, że pobrane wartości zawierają niezbędne dane (obie funkcje viewportSize() i boundingBox() mogą zwrócić wartości jak undefined lub null). W przypadku wystąpienia problemów, zwracamy od razu false - element nie jest w widoku.
const isInViewport = async (element: Locator): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); if ( !viewportSize || !boundingBox) { return false; } };
Warunek !viewportSize || !boundingBox nie jest najłatwiejszy do przeczytania ale zastosowanie takiej konstrukcji pozwala nam płynnie układać narrację w naszej funkcji.
 
Następnie przechodzimy do sprawdzenia elementu.
Czy jest on widoczny?
const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0;
Czy mieści się on w viewport?
const isBoundingBoxInViewport = boundingBox.x + boundingBox.width <= viewportSize.width && boundingBox.y + boundingBox.height <= viewportSize.height;
 
Otrzymane wartości pozwalają nam zwrócić wynik:
return isBoundingBoxVisible && isBoundingBoxInViewport;
 
Cała funkcja będzie wyglądać tak:
const isInViewport = async (element: Locator): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); if ( !viewportSize || !boundingBox) { return false; } const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0; const isBoundingBoxInViewport = boundingBox.x + boundingBox.width <= viewportSize.width && boundingBox.y + boundingBox.height <= viewportSize.height; return isBoundingBoxVisible && isBoundingBoxInViewport; };
 

Wykorzystujemy kod!

 
Mamy test i dwie funkcje, które pozwalają na weryfikację znalezionych elementów.
W ostatnich dwóch linijkach testu dodałem, użycie funkcji:
test("check if found images are in viewport", async ({page}) => { await page.goto('https://playwright.dev/'); const images = await page.getByRole('img').all(); console.log('Found images:', images.length); const imagesInViewport = await getElementsFormViewport(images); console.log('images in viewport:', imagesInViewport.length); }); const getElementsFormViewport = async (elements: Locator[]): Promise<Locator[]> => { const elementsInViewport: Locator[] = []; for (const element of elements) { if (await isInViewport(element)) { elementsInViewport.push(element); } } return elementsInViewport; }; const isInViewport = async (element: Locator): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); if ( !viewportSize || !boundingBox) { return false; } const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0; const isBoundingBoxInViewport = boundingBox.x + boundingBox.width <= viewportSize.width && boundingBox.y + boundingBox.height <= viewportSize.height; return isBoundingBoxVisible && isBoundingBoxInViewport; };
 
Wiemy, że test bez asercji to nie test😁
Proponuję w teście dodać znaną nam asercję toBeInViewport({ ratio: 1 }) aby upewnić się, że nasze obliczenia były poprawne. Zuważ, dodaną parametryzację {ratio: 1} dzięki temu weryfikujemy w identyczny sposób jak nasza funkcja: czy cały element znajduje się w viewport.
Na końcu testu umieszczamy taką pętlę:
for (const image of imagesInViewport) { await expect.soft(image).toBeInViewport({ratio: 1}); }
Zastosowałem expect.soft aby asercja nie zatrzymała pętli w razie niepowodzenia. Dzięki temu dowiemy się czy wszystkie obrazki zostały pobrane poprawnie.
 
Dodatkowo możemy pobrać wszystkie obrazki, aby fizycznie je sprawdzić!
Dodajemy tę akcję przed asercją (zastosowałem znacznik czasu Date.now() aby otrzymać unikalne nazwy plików):
for (const image of imagesInViewport) { await image.screenshot({path: `screenshots/${Date.now()}.png`}); await expect.soft(image).toBeInViewport({ratio: 1}); }
Cały test:
test("check if found images are in viewport", async ({page}) => { await page.goto('https://playwright.dev/'); const images = await page.getByRole('img').all(); console.log('Found images:', images.length); const imagesInViewport = await getElementsFormViewport(images); console.log('Images in viewport:', imagesInViewport.length); for (const image of imagesInViewport) { await image.screenshot({path: `screenshots/${Date.now()}.png`}); await expect.soft(image).toBeInViewport({ratio: 1}); } });
Po odpaleniu testu dostajemy wszystkie obrazki w folderze screenshots i informację na konsoli
Found images: 13 Images in viewport: 5
Możemy usprawnić nazwy zapisywanych plików o podanie wartości x y:
for (const image of imagesInViewport) { const imageBoundingBox = await image.boundingBox(); await image.screenshot({path: `screenshots/imgs/y${ Math.floor(imageBoundingBox!.y)}_x${ Math.floor(imageBoundingBox!.x)}.png`}); await expect.soft(image).toBeInViewport(); }
Przy okazji zminimalizowałem precyzję wartości z imageBoundingBox poprzez zastosowanie Math.floor(). Jeśli nie wiesz jak ona działa proponuję przetestować kod bez zastosowania tej funkcji i sprawdzić nazwy obrazków😉 Z Math.floor() wyglądają tak:
notion image
 
 

Podsumowanie

 
Korzystając z dostępnych funkcji związanych z położeniem elementów, z łatwości można je wykorzystać do kreatywnych testów związanych z danym widokiem😉 W samych testach zastosowałem kilka prostych tricków, które mogą być inspiracją także dla Twoich testów.
 
Warto zaznaczyć, że przypadku zaprezentowanych funkcji sprawdzamy czy wszystkie piksele naszego elementu znajdują się w viewport. Jeśli będziemy chcieli bardziej precyzyjnie określić czy na przykład procentowa wartość elementu znajduje się w viewport musimy zmodyfikować naszą funkcję isInViewport. Jeśli interesuje Ciebie implementacja procentowa przejdź do akapitu Bonus
 

Cały kod testów:

 
test("check if found images are in viewport", async ({page}) => { await page.goto('https://playwright.dev/'); const images = await page.getByRole('img').all(); console.log('Found images:', images.length); const imagesInViewport = await getElementsFormViewport(images); console.log('images in viewport:', imagesInViewport.length); for (const image of imagesInViewport) { const imageBoundingBox = await image.boundingBox(); await image.screenshot({path: `screenshots/imgs/y${ Math.floor(imageBoundingBox!.y)}_x${ Math.floor(imageBoundingBox!.x)}.png`}); await expect.soft(image).toBeInViewport(); } }); const getElementsFormViewport = async (elements: Locator[]): Promise<Locator[]> => { const elementsInViewport: Locator[] = []; for (const element of elements) { if (await isInViewport(element)) { elementsInViewport.push(element); } } return elementsInViewport; }; const isInViewport = async (element: Locator): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); if ( !viewportSize || !boundingBox) { return false; } const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0; const isBoundingBoxInViewport = boundingBox.x + boundingBox.width <= viewportSize.width && boundingBox.y + boundingBox.height <= viewportSize.height; return isBoundingBoxVisible && isBoundingBoxInViewport; }; test("browsers image bounding box", async ({page}) => { await page.goto('https://playwright.dev/'); const browsersImage = page.getByAltText('Browsers'); console.log(await browsersImage.boundingBox()); });

Bonus

Jeśli chcielibyśmy usprawnić naszą funkcję o wyszukiwanie elementów widocznych w zakresie cząstkowym możemy to łatwo zrobić.
 
Po pierwsze refaktorujemy funkcję isInViewport aby przyjęła odpowiedni parametr:
const isInViewport = async (element: Locator, ratio: number = 0): Promise<boolean> => {
Wzorujemy się tu na oryginalnym parametrze z funkcji toBeInViewport() i tam możemy ustawić ratio w zakresie od 0 (dowolna część obrazu znajduje się w vieport) poprzez np. 0.5 (50%) po 1 czyli cały obraz musi być w viewport.
Przechodząc do implementacji, po linii z const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0; dodajemy:
const ratioPixelsWidth = ratio ? boundingBox.width * ratio : 1 const ratioPixelsHeight = ratio ? boundingBox.height * ratio : 1
Sprawdzamy czy ratio jest równe 0, jak nie jest, to mnożymy szerokość i wysokość razy zakładane ratio. Gdy przychodzi wartość 0 to wtedy wystarczy, że jeden piksel będzie w viewport, czyli zwracamy wtedy 1.
Uaktualniamy kolejne linie w funkcji o użycie nowych stałych:
const isBoundingBoxInViewport = boundingBox.x + ratioPixelsWidth <= viewportSize.width && boundingBox.y + ratioPixelsHeight <= viewportSize.height;
I to wszystko. Tak wygląda uaktualniona funkcja:
const isInViewport = async ( element: Locator, ratio: number = 0, ): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); if (!viewportSize || !boundingBox) { return false; } const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0; const ratioPixelsWidth = ratio ? boundingBox.width * ratio : 1; const ratioPixelsHeight = ratio ? boundingBox.height * ratio : 1; const isBoundingBoxInViewport = boundingBox.x + ratioPixelsWidth <= viewportSize.width && boundingBox.y + ratioPixelsHeight <= viewportSize.height; return isBoundingBoxVisible && isBoundingBoxInViewport; };
 
Teraz czas na uaktualnienie funkcji getElementsFormViewport aby też przyjmowała parametr ratio i będzie wygladała tak:
const getElementsFormViewport = async (elements: Locator[], ratio:number = 0): Promise<Locator[]> => { const elementsInViewport: Locator[] = []; for (const element of elements) { if (await isInViewport(element, ratio)) { elementsInViewport.push(element); } } return elementsInViewport; };
 
Na koniec pozostał test. Tutaj dodajemy sobie stałą ratio po to aby manipulować wartością w teście.
test("check if found images are in viewport", async ({page}) => { const ratio = 0.5;
Następnie aktualizujemy miejsca gdzie możemy tę stałą użyć.
Pierwsze miejsce to wywołanie funkcji:
const imagesInViewport = await getElementsFormViewport(images, ratio);
I asercja - gdyż tam też weryfikujemy poprawność naszych założeń:
await expect.soft(image).toBeInViewport({ ratio: ratio});
Cały kod testu będzie wyglądał tak:
test('check if found images are in viewport', async ({ page }) => { const ratio = 0.5; await page.goto('https://unsplash.com'); const images = await page.getByRole('img').all(); console.log('Found images:', images.length); const imagesInViewport = await getElementsFormViewport(images, ratio); console.log('images in viewport:', imagesInViewport.length); for (const image of imagesInViewport) { const imageBoundingBox = await image.boundingBox(); await image.screenshot({ path: `screenshots/imgs/y${Math.floor(imageBoundingBox!.y)}_x${Math.floor( imageBoundingBox!.x, )}.png`, }); await expect.soft(image).toBeInViewport({ ratio: ratio }); } });
No i testujemy nowy serwis - gdyż tam łatwo zobaczymy różnice.
 

Zadanie dla Ciebie

Uruchom testy z ratio na różnych ustawieniach 0, 1, czy 0.5.
Zobacz ile obrazów jest przechwytywanych. Czy funkcja działa jak należy?

Cały kod

import { expect, test, Locator } from '@playwright/test'; test('check if found images are in viewport', async ({ page }) => { const ratio = 0.5; await page.goto('https://unsplash.com'); const images = await page.getByRole('img').all(); console.log('Found images:', images.length); const imagesInViewport = await getElementsFormViewport(images, ratio); console.log('images in viewport:', imagesInViewport.length); for (const image of imagesInViewport) { const imageBoundingBox = await image.boundingBox(); await image.screenshot({ path: `screenshots/imgs/y${Math.floor(imageBoundingBox!.y)}_x${Math.floor( imageBoundingBox!.x, )}.png`, }); await expect.soft(image).toBeInViewport({ ratio: ratio }); } }); const getElementsFormViewport = async ( elements: Locator[], ratio: number = 0, ): Promise<Locator[]> => { const elementsInViewport: Locator[] = []; for (const element of elements) { if (await isInViewport(element, ratio)) { elementsInViewport.push(element); } } return elementsInViewport; }; const isInViewport = async ( element: Locator, ratio: number = 0, ): Promise<boolean> => { const viewportSize = element.page().viewportSize(); const boundingBox = await element.boundingBox(); if (!viewportSize || !boundingBox) { return false; } const isBoundingBoxVisible = boundingBox.x >= 0 && boundingBox.y >= 0; const ratioPixelsWidth = ratio ? boundingBox.width * ratio : 1; const ratioPixelsHeight = ratio ? boundingBox.height * ratio : 1; const isBoundingBoxInViewport = boundingBox.x + ratioPixelsWidth <= viewportSize.width && boundingBox.y + ratioPixelsHeight <= viewportSize.height; return isBoundingBoxVisible && isBoundingBoxInViewport; };