← Valtive QA Quiz quiz.valtive.io/notes/webqa valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для мануальних і автоматизаторів, які тестують вебзастосунки й готуються до технічної співбесіди. Гайд пояснює, як браузер будує сторінку, де живе стан користувача, що відбувається в мережі і як усе це перетворити на доказовий баг-репорт або стабільний локатор. Кожне питання квізу Web for QA покрите щонайменше однією карткою.
For manual testers and automation engineers who test web applications and are preparing for a technical interview. The guide explains how the browser builds a page, where the user's state lives, what happens on the network, and how to turn all of that into a bug report with evidence or a stable locator. Every question of the Web for QA quiz is covered by at least one card.
Як користуватисьHow to use it
Фільтруй питання за рівнем або розділом. Відкрий картку, прочитай відповідь і приклад, а тоді повтори їх руками в DevTools на будь-якому сайті. Наостанок закрий текст і перекажи відповідь уголос за блоком 🗣 Як читати вголос — так ти тренуєш живу розмову з інтерв'юером, а не лише впізнавання тексту очима.
Filter the questions by level or by section. Open a card, read the answer and the example, then repeat them by hand in DevTools on any site. Finally hide the text and retell the answer out loud using the 🗣 How to say it out loud block — that trains a live conversation with the interviewer, not just recognising text on the page.
Три якорі — з них випливає більшість відповідейThree anchors — most answers follow from them
ЯКІР 1 · DOM і accessible namesANCHOR 1 · DOM and accessible names
Тест бачить не HTML-файл, а живе дерево DOM і дерево доступності. Роль і доступне ім'я елемента — це те, як його знаходить людина, скрінрідер і getByRole.
A test sees not the HTML file but the live DOM tree and the accessibility tree. An element's role and accessible name are how a person, a screen reader and getByRole find it.
ЯКІР 2 · Сховища і станANCHOR 2 · Storage and state
Сесія живе або в cookie, яку браузер шле сам, або в Web Storage, звідки її бере JS. Від цього залежать вкладки, логаут, XSS-ризики і те, як швидко логінитись у тестах.
A session lives either in a cookie the browser sends on its own or in Web Storage, where JS has to pick it up. Tabs, logout, XSS risk and fast test logins all depend on that choice.
ЯКІР 3 · Мережа і кешANCHOR 3 · Network and cache
Будь-який «дивний» баг спершу шукай у вкладці Network: статус, заголовки, таймінги, звідки взята відповідь. CORS, 304 і стара версія після релізу читаються саме там.
Look for any "strange" bug in the Network tab first: status, headers, timings and where the response came from. CORS, 304 and a stale version after a release are all read right there.
49
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
40/40
питань квізу покритоquiz questions covered
📚 6 РОЗДІЛІВ📚 6 SECTIONS
—
Junior
—
Middle
—
Senior
▶ 1 10
1 Що саме показує тестеру вкладка Elements у DevTools і чим це відрізняється від «коду сторінки»? What exactly does the Elements tab in DevTools show a tester, and how is it different from the page source?
HTMLHTML Junior ▶

Що це. Elements показує живе дерево DOM — те, що браузер побудував з HTML і потім змінив скриптами, — плюс обчислені стилі, box model, обробники подій і дерево доступності для вибраного вузла. «Переглянути код сторінки» (Ctrl+U) показує інше: сирий HTML, який сервер віддав у відповіді, без жодних змін від JavaScript.

What it is. Elements shows the live DOM tree — what the browser built from the HTML and then changed with scripts — plus computed styles, the box model, event listeners and the accessibility tree for the selected node. View Page Source (Ctrl+U) shows something else: the raw HTML the server sent in the response, with none of the JavaScript changes.

Навіщо тестеру. Саме з DOM працюють локатори Playwright, Cypress і Selenium, тож Elements — це те, що «бачить» автотест у цю мить. Тут видно, чи елемент справді є в дереві, які в нього атрибути (id, name, aria-*, data-testid), чи не прихований він через display: none, і яку роль та доступне ім'я йому дає панель Accessibility.

Why a tester cares. Playwright, Cypress and Selenium locators all work against the DOM, so Elements is what an automated test "sees" at that moment. You can check whether the element really is in the tree, what attributes it has (id, name, aria-*, data-testid), whether display: none hides it, and which role and accessible name the Accessibility pane gives it.

Приклад. На сторінці товару кнопка «Купити» не натискається. В Elements обираєш її (Ctrl+Shift+C), у вкладці Styles бачиш pointer-events: none від класу .is-loading, який не знявся після запиту. Це вже конкретний баг-репорт із причиною, а не «кнопка не працює».

Example. The "Buy" button on a product page does nothing. You pick it in Elements (Ctrl+Shift+C), and the Styles pane shows pointer-events: none coming from an .is-loading class that was never removed after the request. Now the bug report names a cause instead of saying "the button is broken".

Підводні камені. Правки в Elements живуть лише до перезавантаження, тому «виправив у DevTools» — це гіпотеза, а не фікс. Ще одна пастка: DevTools сам нормалізує розмітку, наприклад додає пропущений <tbody> у таблицю, тож дерево не завжди збігається з файлом у репозиторії.

Pitfalls. Edits made in Elements last only until a reload, so "I fixed it in DevTools" is a hypothesis, not a fix. Another trap: the browser normalises markup, for instance it inserts a missing <tbody> into a table, so the tree does not always match the file in the repository.

Уточнююче питання. «Як скопіювати селектор з Elements?» — через Copy → Copy selector, але такий селектор зазвичай крихкий і годиться лише як відправна точка, а не як готовий локатор.

Follow-up question. "How do you copy a selector from Elements?" Through Copy → Copy selector, but that selector is usually brittle and is only a starting point, not a finished locator.

Що бачить Elements після роботи скрипта
What Elements shows after a script has run
<!-- Відповідь сервера (Ctrl+U) -->
<button class="buy">Купити</button>

<!-- Живий DOM у вкладці Elements -->
<button class="buy is-loading" disabled aria-busy="true">Купити</button>
<!-- Server response (Ctrl+U) -->
<button class="buy">Buy</button>

<!-- Live DOM in the Elements tab -->
<button class="buy is-loading" disabled aria-busy="true">Buy</button>
🔧 Розбір синтаксису: <!-- ... --> — HTML-коментар, браузер його не показує. Перший блок — рядок із сирої відповіді сервера, другий — той самий вузол після JS: скрипт додав клас is-loading, булевий атрибут disabled (достатньо самої назви, значення не потрібне) і aria-busy="true", який каже скрінрідеру, що елемент зайнятий.
🔧 Syntax breakdown: <!-- ... --> is an HTML comment the browser never renders. The first block is the line from the raw server response, the second is the same node after JS ran: the script added the is-loading class, the boolean disabled attribute (its name alone is enough, no value needed) and aria-busy="true", which tells a screen reader the element is busy.
💡 Простими словами: Код сторінки — це те, що прислав сервер; Elements — те, що є на сторінці зараз. Автотест живе у другому світі.
💡 In plain words: Page source is what the server sent; Elements is what is on the page right now. An automated test lives in the second world.
🗣 Як читати вголос Я розводжу два поняття. Код сторінки через Ctrl+U — це сирий HTML з відповіді сервера. Elements — це живий DOM, тобто вже після того, як відпрацював JavaScript, плюс стилі, обробники і панель доступності. Мої локатори в автотестах шукають саме в DOM, тому я завжди звіряюсь з Elements. Наприклад, якщо кнопка не клікається, я виділяю її і дивлюсь у Styles — часто там якийсь pointer-events: none або клас завантаження, що не зник. І я пам'ятаю, що правка прямо в DevTools тримається до перезавантаження, тож це спосіб перевірити гіпотезу, а не виправлення.
🗣 How to say it out loud I keep two things apart. View Source, Ctrl+U, is the raw HTML from the server response. Elements is the live DOM, after JavaScript has done its work, together with styles, listeners and the accessibility pane. My automation locators search that DOM, so Elements is where I check them. If a button will not click, for example, I select it and look at Styles — quite often there is a pointer-events: none or a loading class that never went away. And I remember that an edit made right in DevTools only survives until reload, so it tests a hypothesis rather than fixing anything.
2 Що знайде пошук за селектором .error в Elements і чому збіг ще не означає, що помилку видно? What does a search for the selector .error in Elements match, and why does a match not mean the error is visible?
HTMLHTML Junior ▶

Що це. Крапка в CSS-селекторі означає клас, тож .error знаходить усі елементи, в атрибуті class яких є слово error, — незалежно від тегу. Решітка #error шукала б елемент з id="error", а голе error — теги <error>, яких у звичайному HTML немає. Текст «error» усередині елемента селектор класу не шукає взагалі.

What it is. A dot in a CSS selector means a class, so .error matches every element whose class attribute contains the word error, whatever its tag. A hash, #error, would look for the element with id="error", and a bare error would look for <error> tags that ordinary HTML does not have. A class selector never looks at the text "error" inside an element.

Навіщо. Пошук у Elements (Ctrl+F на панелі DOM) приймає звичайний рядок, CSS-селектор або XPath і показує лічильник збігів «1 of 12». Це найшвидший спосіб перевірити локатор до того, як писати його в тест: якщо лічильник показує 12, а тест очікує один елемент, Playwright упаде з помилкою strict mode violation.

Why it matters. Search in Elements (Ctrl+F in the DOM panel) accepts a plain string, a CSS selector or an XPath and shows a counter like "1 of 12". It is the fastest way to try a locator before putting it into a test: if the counter says 12 and the test expects one element, Playwright fails with a strict mode violation.

Приклад. На формі реєстрації після сабміту ти шукаєш .error і бачиш 12 збігів. Частина з них — порожні <span class="error"> під кожним полем, які верстальник рендерить заздалегідь і ховає через display: none. Реальна помилка лише одна, під полем Email, і її треба знаходити прив'язкою до поля, а не загальним класом.

Example. After submitting a sign-up form you search for .error and get 12 matches. Most of them are empty <span class="error"> nodes under each field that the markup renders up front and hides with display: none. The real error is a single one under the Email field, and it should be found through its field rather than through a shared class.

Підводні камені. Збіг у DOM не дорівнює видимості: елемент може бути прихований стилем, мати нульовий розмір або стояти за межами екрана. Ще класи часто комбінуються: .field.error (обидва класи на одному вузлі) і .field .error (нащадок) — зовсім різні селектори, а пробіл між ними легко пропустити.

Pitfalls. A match in the DOM is not visibility: the element may be hidden by a style, have zero size or sit off screen. Classes also combine: .field.error (both classes on the same node) and .field .error (a descendant) are completely different selectors, and the space between them is easy to miss.

Уточнююче питання. «Як перевірити, що помилка справді показана?» — у тесті через toBeVisible() на конкретному локаторі, а вручну через підсвічування вузла в Elements і вкладку Computed.

Follow-up question. "How do you check the error is really shown?" In a test with toBeVisible() on a specific locator, and by hand by highlighting the node in Elements and checking the Computed tab.

Три селектори — три різні запити
Three selectors, three different queries
// Ctrl+F у панелі Elements приймає ці ж рядки
document.querySelectorAll('.error').length;   // 12 — усі з класом error
document.querySelectorAll('#error').length;   // 0 — шукає id="error"
document.querySelectorAll('.error:not([hidden])').length;

// У Playwright загальний клас ламає strict mode
await page.locator('.error').textContent();   // помилка: 12 елементів
await expect(page.getByText('Невірний email')).toBeVisible();
// Ctrl+F in the Elements panel accepts the same strings
document.querySelectorAll('.error').length;   // 12 — everything with class error
document.querySelectorAll('#error').length;   // 0 — looks for id="error"
document.querySelectorAll('.error:not([hidden])').length;

// In Playwright a shared class breaks strict mode
await page.locator('.error').textContent();   // error: resolved to 12 elements
await expect(page.getByText('Invalid email')).toBeVisible();
🔧 Розбір синтаксису: querySelectorAll(sel) повертає всі вузли, що підходять під CSS-селектор, а .length — їхню кількість. :not([hidden]) відкидає елементи з атрибутом hidden, але не бачить прихованих через CSS. page.locator(...).textContent() — дія, яка вимагає рівно одного елемента, тому при 12 збігах Playwright кидає strict mode violation.
🔧 Syntax breakdown: querySelectorAll(sel) returns every node matching a CSS selector, and .length is how many there are. :not([hidden]) drops elements with the hidden attribute but does not see ones hidden through CSS. page.locator(...).textContent() is an action that needs exactly one element, so with 12 matches Playwright throws a strict mode violation.
💡 Простими словами: Крапка — клас, решітка — id. Клас може стояти на скількох завгодно елементах, і частина з них буде невидимою.
💡 In plain words: A dot is a class, a hash is an id. A class can sit on any number of elements, and some of them will be invisible.
🗣 Як читати вголос Селектор з крапкою — це клас, тому .error поверне всі елементи з таким класом, будь-який тег, а не один. Id позначається решіткою, і лише він за задумом унікальний. Я завжди вставляю селектор у пошук Elements і дивлюсь на лічильник: якщо там дванадцять збігів, тест на один елемент впаде на strict mode. І окремо я пам'ятаю, що знайдений вузол ще не означає видиму помилку — дуже часто це порожні приховані контейнери під кожним полем. Тому в тесті я перевіряю видимість конкретного повідомлення, прив'язаного до поля, а не просто існування класу в DOM.
🗣 How to say it out loud A selector with a dot is a class, so .error gives me every element with that class, any tag, not just one. An id uses a hash, and only an id is meant to be unique. I always paste the selector into the Elements search and look at the counter: if it says twelve, a test expecting one element dies on strict mode. Separately, I keep in mind that a found node is not a visible error — very often those are empty hidden containers sitting under every field. So in the test I assert visibility of the specific message tied to its field.
3 Що таке семантичні теги й орієнтири (landmarks) і в якому порядку вони йдуть на типовій сторінці? What are semantic tags and landmarks, and in what order do they appear on a typical page?
HTMLHTML Junior ▶

Що це. Семантичний тег описує призначення блоку, а не його вигляд. Частина таких тегів автоматично стає орієнтиром (landmark) — регіоном, до якого скрінрідер може стрибнути одним натисканням. Типовий порядок у розмітці: <header> (роль banner), <nav> (роль navigation), <main> (роль main), <footer> (роль contentinfo). Бічна колонка <aside> отримує роль complementary.

What it is. A semantic tag describes what a block is for, not how it looks. Some of these tags automatically become landmarks — regions a screen reader can jump to with one keystroke. The usual order in markup is <header> (role banner), <nav> (role navigation), <main> (role main), <footer> (role contentinfo). A sidebar <aside> gets the role complementary.

Навіщо. Ці ролі браузер видає безкоштовно, без жодного aria--атрибута. Тому getByRole('main') чи getByRole('navigation') працюють на семантичній сторінці і дозволяють звузити пошук: «кнопка Увійти всередині шапки», а не «третя кнопка на сторінці». На сторінці, зверстаній суцільно на <div>, такі локатори не знайдуть нічого.

Why it matters. The browser hands out these roles for free, without a single aria- attribute. That is why getByRole('main') or getByRole('navigation') work on a semantic page and let you narrow a search: "the Sign in button inside the header" instead of "the third button on the page". On a page built entirely from <div> elements such locators find nothing.

Приклад. У шапці й у футері є посилання «Контакти». Локатор getByRole('link', { name: 'Контакти' }) знайде два елементи, а getByRole('contentinfo').getByRole('link', ...) — лише той, що у футері. Паралельно це перевірка доступності: користувач скрінрідера бачить ту саму структуру регіонів.

Example. The header and the footer both have a "Contact" link. getByRole('link', { name: 'Contact' }) resolves to two elements, while getByRole('contentinfo').getByRole('link', ...) gets only the footer one. At the same time it checks accessibility: a screen reader user gets exactly the same map of regions.

Підводні камені. <main> має бути один видимий на сторінку. <header> і <footer> стають banner і contentinfo, лише коли вони не вкладені в <article>, <section> чи <main>: шапка картки товару — вже не банер сайту. Якщо навігацій кілька, кожній варто дати aria-label, інакше скрінрідер озвучить дві однакові «навігації».

Pitfalls. There should be one visible <main> per page. <header> and <footer> become banner and contentinfo only when they are not nested inside <article>, <section> or <main>: the header of a product card is not the site banner. When there are several navigations, give each an aria-label, otherwise a screen reader announces two identical "navigation" regions.

Уточнююче питання. «Як подивитись орієнтири без скрінрідера?» — у DevTools у панелі Accessibility (увімкни full-page accessibility tree) або розширенням на кшталт axe DevTools.

Follow-up question. "How do you see the landmarks without a screen reader?" In the DevTools Accessibility pane (turn on the full-page accessibility tree) or with an extension such as axe DevTools.

Каркас сторінки і локатори по орієнтирах
Page skeleton and landmark locators
<header>  <!-- роль banner -->
  <nav aria-label="Головне меню">...</nav>  <!-- navigation -->
</header>
<main>...</main>  <!-- main, лише один -->
<aside>...</aside>  <!-- complementary -->
<footer>  <!-- contentinfo -->
  <a href="/contacts">Контакти</a>
</footer>

<script>
// Playwright: посилання саме у футері
page.getByRole('contentinfo').getByRole('link', { name: 'Контакти' });
</script>
<header>  <!-- role banner -->
  <nav aria-label="Main menu">...</nav>  <!-- navigation -->
</header>
<main>...</main>  <!-- main, only one -->
<aside>...</aside>  <!-- complementary -->
<footer>  <!-- contentinfo -->
  <a href="/contacts">Contact</a>
</footer>

<script>
// Playwright: the link inside the footer only
page.getByRole('contentinfo').getByRole('link', { name: 'Contact' });
</script>
🔧 Розбір синтаксису: Коментар після кожного тегу показує неявну роль, яку браузер призначає без role=. aria-label на <nav> дає регіону ім'я, щоб дві навігації різнилися. У Playwright ланцюжок getByRole('contentinfo').getByRole('link', ...) шукає друге лише всередині першого — так звужується область пошуку.
🔧 Syntax breakdown: The comment after each tag shows the implicit role the browser assigns with no role= at all. aria-label on <nav> names the region so two navigations can be told apart. In Playwright the chain getByRole('contentinfo').getByRole('link', ...) searches for the second only inside the first, which narrows the scope.
💡 Простими словами: Шапка, меню, основний вміст, підвал — чотири теги, які дають ролі даром і роблять сторінку зрозумілою і скрінрідеру, і тесту.
💡 In plain words: Header, menu, main content, footer: four tags that give roles for free and make the page readable for a screen reader and a test alike.
🗣 Як читати вголос Семантичні теги кажуть, для чого блок, а не як він виглядає. Каркас типової сторінки йде так: header з роллю banner, всередині чи поруч nav — навігація, потім main, який має бути один, і наприкінці footer з роллю contentinfo. Aside, якщо є, — complementary. Мені як тестувальнику це дає дві речі. По-перше, локатори по ролях працюють без жодної aria-розмітки, і я можу шукати посилання саме у футері. По-друге, якщо getByRole main нічого не знаходить, це вже сигнал про проблему з доступністю, бо скрінрідер теж не має куди стрибнути.
🗣 How to say it out loud Semantic tags say what a block is for, not how it looks. A typical skeleton goes header with the banner role, a nav for navigation inside or next to it, then main — and there should be exactly one — and finally footer with the contentinfo role. An aside, if present, is complementary. As a tester I get two things out of it. First, role locators work with no aria markup, so I can target the link that lives in the footer. Second, if getByRole main returns nothing, that already flags an accessibility problem, because a screen reader has nowhere to jump either.
4 Чому пропуск рівнів заголовків (h2 → h4 заради розміру шрифту) — це баг, а не косметика? Why is skipping heading levels (h2 → h4 just for the font size) a bug and not cosmetics?
HTMLHTML Middle ▶

Що це. Заголовки <h1>–<h6> утворюють зміст сторінки: рівень означає вкладеність розділу, а не кегль. Коли підзаголовок роблять <h4> одразу після <h1>, бо «h4 якраз потрібного розміру», у змісті з'являється діра — наче пропущено розділи другого й третього рівня.

What it is. Headings <h1>–<h6> form the page's table of contents: the level means how deep a section is nested, not the type size. When a subheading becomes <h4> straight after <h1> because "h4 is just the right size", the outline gets a hole, as if second- and third-level sections had gone missing.

Навіщо це тестувати. Користувачі скрінрідерів часто навігують саме заголовками: клавіша H у NVDA чи ротор у VoiceOver показують список, схожий на зміст книги. Діра в рівнях читається як загублена частина сторінки, а неправильна ієрархія ламає розуміння, що чому підпорядковано. Це порушення WCAG 1.3.1 «Info and Relationships», тобто дефект доступності з конкретним критерієм, а не питання смаку.

Why test it. Screen reader users often move around by headings: the H key in NVDA or the rotor in VoiceOver brings up a list that works like a book's contents. A gap in the levels reads as a lost part of the page, and a wrong hierarchy breaks the understanding of what belongs under what. It violates WCAG 1.3.1 "Info and Relationships", so it is an accessibility defect with a concrete criterion, not a matter of taste.

Приклад. На сторінці оформлення замовлення <h1> «Оформлення», а блоки «Доставка» й «Оплата» зверстано як <h4>. У баг-репорті вказуєш: очікувано <h2>, фактично <h4>, критерій WCAG 1.3.1, спосіб перевірки — список заголовків у axe чи Accessibility tree. Виправлення — правильний тег плюс CSS-клас для розміру.

Example. On a checkout page the <h1> is "Checkout", while the "Delivery" and "Payment" blocks are marked up as <h4>. The bug report says: expected <h2>, actual <h4>, criterion WCAG 1.3.1, checked through the heading list in axe or the Accessibility tree. The fix is the correct tag plus a CSS class for the size.

Підводні камені. Протилежна помилка — стилізований <div class="title">, який виглядає як заголовок, але в дереві доступності ним не є; getByRole('heading') його не знайде. Ще одна: кілька <h1> на сторінці без потреби. Перевіряти ієрархію варто по DOM, а не по скриншоту, бо на око розмір нічого не скаже.

Pitfalls. The opposite mistake is a styled <div class="title"> that looks like a heading but is not one in the accessibility tree; getByRole('heading') will not find it. Another is several <h1> elements for no reason. Check the hierarchy in the DOM, not on a screenshot, because visual size tells you nothing here.

Уточнююче питання. «Як це автоматизувати?» — axe-core має правило heading-order, а в Playwright можна перевірити рівень через опцію level у getByRole('heading').

Follow-up question. "How would you automate it?" axe-core has a heading-order rule, and in Playwright you can check the level with the level option of getByRole('heading').

Рівень — у тегу, розмір — у CSS
Level in the tag, size in CSS
// Було: <h1>Оформлення</h1> ... <h4>Доставка</h4>
// Стало: <h2 class="section-title">Доставка</h2>, розмір задає CSS

await expect(
  page.getByRole('heading', { level: 2, name: 'Доставка' })
).toBeVisible();

// Консоль DevTools: швидкий зміст сторінки
[...document.querySelectorAll('h1,h2,h3,h4,h5,h6')]
  .map(h => h.tagName + ' ' + h.textContent.trim());
// Before: <h1>Checkout</h1> ... <h4>Delivery</h4>
// After: <h2 class="section-title">Delivery</h2>, CSS sets the size

await expect(
  page.getByRole('heading', { level: 2, name: 'Delivery' })
).toBeVisible();

// DevTools console: a quick outline of the page
[...document.querySelectorAll('h1,h2,h3,h4,h5,h6')]
  .map(h => h.tagName + ' ' + h.textContent.trim());
🔧 Розбір синтаксису: getByRole('heading', { level: 2, name }) шукає заголовок саме другого рівня з таким доступним ім'ям — тест упаде, якщо хтось знову поставить <h4>. У консольному рядку [...NodeList] перетворює список вузлів на масив, а .map будує з нього «зміст» у вигляді «H2 Доставка», де пропуски рівнів видно одразу.
🔧 Syntax breakdown: getByRole('heading', { level: 2, name }) looks for a second-level heading with that accessible name, so the test fails if someone puts <h4> back. In the console line [...NodeList] turns the node list into an array, and .map builds an outline like "H2 Delivery" where skipped levels jump out immediately.
💡 Простими словами: Розмір шрифту — справа CSS, рівень заголовка — справа змісту. Пропущений рівень — це пропущений розділ для того, хто не бачить екрана.
💡 In plain words: Font size is CSS's job, heading level is the outline's job. A skipped level is a missing section for someone who cannot see the screen.
🗣 Як читати вголос Для мене рівень заголовка — це про структуру, а не про те, наскільки великі літери. Користувач скрінрідера гортає сторінку по заголовках, як по змісту, і якщо після h1 одразу йде h4, він чує, ніби два рівні кудись зникли. Це конкретне порушення WCAG 1.3.1, тому я заводжу його як баг доступності з очікуваним і фактичним тегом. Виправлення просте: правильний рівень і клас для розміру. А щоб воно не повернулось, я перевіряю в тесті роль heading з потрібним level і даю axe прогнати правило про порядок заголовків на кожному прогоні.
🗣 How to say it out loud To me a heading level is about structure, not about how large the letters are. A screen reader user skims the page by headings the way you skim a table of contents, and if h4 comes right after h1 it sounds like two levels vanished. That is a concrete WCAG 1.3.1 failure, so I log it as an accessibility bug with the expected and actual tag. The fix is easy: the right level plus a class for the size. To keep it from coming back, I assert the heading role with the right level in a test and let axe run its heading order rule.
5 Що таке accessible name і звідки браузер його бере: aria-labelledby, aria-label, label for=id чи вміст? What is an accessible name, and where does the browser take it from: aria-labelledby, aria-label, label for=id or the content?
HTMLHTML Middle ▶

Що це. Accessible name — рядок, яким скрінрідер озвучує елемент і за яким його шукають getByRole(..., { name }) та getByLabel. Браузер обчислює його за пріоритетом: спершу aria-labelledby (текст елементів з указаними id), потім aria-label, далі нативні джерела — <label> для полів, alt для картинок, а для кнопок і посилань — їхній текстовий вміст. Останній запасний варіант — title або сіра підказка всередині порожнього поля.

What it is. The accessible name is the string a screen reader announces for an element and the one getByRole(..., { name }) and getByLabel match against. The browser computes it by priority: first aria-labelledby (the text of the elements with the given ids), then aria-label, then native sources — a <label> for form fields, alt for images, and the text content for buttons and links. The last fallback is title or the grey hint text inside an empty field.

Навіщо. Звідси два класичні питання квізу. Для <button aria-label="Видалити товар">✕</button> ім'ям стане значення aria-label, а не хрестик усередині. А getByLabel('Email') не знайде поле, якщо for у <label> не збігається з id інпута: без цього зв'язку мітка — просто текст поруч, клік по ній не фокусує поле, а поле лишається без імені.

Why it matters. This explains two classic quiz questions. For <button aria-label="Remove item">✕</button> the name is the aria-label value, not the cross inside. And getByLabel('Email') will not find the field if for on the <label> does not match the input's id: without that link the label is just nearby text, clicking it does not focus the field, and the field has no name.

Приклад. Розробник написав for="email", а в інпута тільки name="email" без id. На екрані все виглядає правильно, але тест падає, а скрінрідер каже «поле редагування» без назви. Виправлення — додати id="email" або обгорнути <input> у сам <label>.

Example. A developer wrote for="email" while the input only has name="email" and no id. On screen everything looks fine, yet the test fails and a screen reader says "edit text" with no label. The fix is to add id="email" or to wrap the <input> inside the <label> itself.

Підводні камені. for посилається саме на id, атрибут name для цього не рахується. Сіра підказка в полі — не мітка: вона зникає під час введення і дає слабке ім'я. Якщо aria-label розходиться з видимим текстом, користувач голосового керування не зможе сказати «натисни Видалити», коли на кнопці написано інше, — це порушення WCAG 2.5.3 Label in Name.

Pitfalls. for points to an id; the name attribute does not count. The grey hint text inside a field is not a label: it disappears while typing and gives a weak name. When aria-label differs from the visible text, a voice control user cannot say "click Remove" if the button shows something else — that fails WCAG 2.5.3 Label in Name.

Уточнююче питання. «Де подивитись обчислене ім'я?» — у DevTools, панель Accessibility, поле Name, де видно ще й джерело, з якого браузер його взяв.

Follow-up question. "Where can you see the computed name?" In the DevTools Accessibility pane, in the Name field, which also shows the source the browser took it from.

Звідки береться ім'я і як його шукає тест
Where the name comes from and how a test finds it
<!-- Зламано: for вказує на id, якого немає -->
<label for="email">Email</label>
<input name="email" type="email">

<!-- Працює: for збігається з id -->
<label for="email">Email</label>
<input id="email" name="email" type="email">

<!-- Ім'я = aria-label, а не символ усередині -->
<button aria-label="Видалити товар">✕</button>

<script>
await page.getByLabel('Email').fill('qa@example.com');
await page.getByRole('button', { name: 'Видалити товар' }).click();
</script>
<!-- Broken: for points to an id that does not exist -->
<label for="email">Email</label>
<input name="email" type="email">

<!-- Works: for matches the id -->
<label for="email">Email</label>
<input id="email" name="email" type="email">

<!-- Name = aria-label, not the symbol inside -->
<button aria-label="Remove item">✕</button>

<script>
await page.getByLabel('Email').fill('qa@example.com');
await page.getByRole('button', { name: 'Remove item' }).click();
</script>
🔧 Розбір синтаксису: for="email" — посилання з мітки на елемент з id="email"; name лише задає ключ поля у формі при сабміті. aria-label перекриває текстовий вміст кнопки при обчисленні імені. getByLabel шукає поле за текстом прив'язаної мітки, а getByRole('button', { name }) — за обчисленим accessible name.
🔧 Syntax breakdown: for="email" is a link from the label to the element with id="email"; name only sets the field's key when the form is submitted. aria-label overrides the button's text content when the name is computed. getByLabel finds a field by the text of its linked label, and getByRole('button', { name }) matches the computed accessible name.
💡 Простими словами: Ім'я елемента — це те, як його назве скрінрідер. Мітка прив'язується через for до id, а aria-label перебиває все, що написано всередині.
💡 In plain words: An element's name is what a screen reader calls it. A label links through for to an id, and aria-label beats whatever is written inside.
🗣 Як читати вголос Accessible name — це назва, яку озвучує скрінрідер, і саме за нею шукають getByRole з name і getByLabel. Браузер бере її за пріоритетом: aria-labelledby, потім aria-label, потім нативні речі на кшталт мітки чи alt, і вже потім текст усередині. Тому кнопка-хрестик з aria-label «Видалити товар» називається саме так, а не хрестиком. Найчастіший баг, який я бачу, — мітка з for, що не збігається з id поля: на екрані все гарно, а поле без імені і getByLabel падає. Обчислене ім'я і його джерело я звіряю в панелі Accessibility у DevTools.
🗣 How to say it out loud The accessible name is what a screen reader announces, and it is exactly what getByRole with a name and getByLabel match on. The browser picks it by priority: aria-labelledby, then aria-label, then native sources like a label element or alt, and only after that the text inside. So a cross button with aria-label Remove item is called Remove item, not cross. The bug I run into most is a label whose for does not match the field's id: the screen looks perfect, but the field has no name and getByLabel fails. I verify the computed name in the Accessibility pane.
6 Чому getByRole('link', { name: 'Купити' }) не знаходить <a class="btn">Купити</a>? Why does getByRole('link', { name: 'Buy' }) not find <a class="btn">Buy</a>?
HTMLHTML Middle ▶

Що це. Роль елементу дає не сам тег, а тег разом з атрибутами. За специфікацією HTML-AAM <a> отримує роль link лише тоді, коли має атрибут href. Без нього це просто контейнер з текстом (роль generic): його не видно в списку посилань скрінрідера, на нього не можна перейти клавішею Tab, і локатор за роллю link його чесно не знаходить.

What it is. A role comes not from the tag alone but from the tag together with its attributes. According to HTML-AAM, <a> gets the link role only when it has an href attribute. Without it, it is just a text container (role generic): it is missing from a screen reader's list of links, the Tab key never reaches it, and a link role locator honestly finds nothing.

Навіщо. Така розмітка зазвичай з'являється, коли «посилання» насправді обробляє клік через JavaScript. Мишею все працює, тому баг непомітний на ручному прогоні. Але користувач клавіатури не дійде до цього елемента, скрінрідер його не оголосить, а тест по ролі впаде рівно там, де людина теж застрягне. Тобто падіння тесту — не проблема локатора, а знайдений дефект доступності.

Why it matters. This markup usually appears when a "link" really handles a click through JavaScript. With a mouse everything works, so a manual run never notices. But a keyboard user cannot reach the element, a screen reader does not announce it, and a role-based test fails exactly where a person gets stuck too. The failing test is not a locator problem — it has found an accessibility defect.

Приклад. Кнопка «Купити» на картці товару — <a class="btn" onclick="addToCart()">. Правильні варіанти: якщо дія змінює стан (додає в кошик), це <button type="button">; якщо веде на іншу сторінку — <a href="/cart">. Схожа історія з <input type="hidden">: він узагалі поза деревом доступності.

Example. The "Buy" control on a product card is <a class="btn" onclick="addToCart()">. The right options: if the action changes state (adds to the cart), it is a <button type="button">; if it leads to another page, it is <a href="/cart">. The same story applies to <input type="hidden">, which is not in the accessibility tree at all.

Підводні камені. Не «лагодь» тест, замінюючи getByRole на locator('.btn'), — так ти приховуєш баг. Не пропонуй і role="link" з tabindex як основне рішення: доведеться вручну дописувати обробку Enter і фокус, а нативний тег дає це даром. І пам'ятай, що href="#" формально повертає роль, але посилання в нікуди теж варто обговорити з командою.

Pitfalls. Do not "fix" the test by swapping getByRole for locator('.btn') — that hides the bug. Do not push role="link" plus tabindex as the main fix either: you then have to hand-write Enter handling and focus, while the native tag gives it for free. And note that href="#" formally brings the role back, but a link to nowhere is still worth raising with the team.

Уточнююче питання. «Як швидко перевірити роль?» — виділи елемент в Elements і подивись Role у панелі Accessibility або скористайся Pick locator у Playwright codegen.

Follow-up question. "How do you check a role quickly?" Select the element in Elements and read Role in the Accessibility pane, or use Pick locator in Playwright codegen.

Роль залежить від атрибутів
The role depends on attributes
<!-- Роль generic: не посилання, Tab не доходить -->
<a class="btn" onclick="addToCart()">Купити</a>

<!-- Дія на сторінці: кнопка -->
<button type="button" class="btn">Купити</button>

<!-- Перехід на іншу сторінку: посилання з href -->
<a class="btn" href="/cart">Купити</a>

<script>
await page.getByRole('button', { name: 'Купити' }).click();
</script>
<!-- Role generic: not a link, Tab never reaches it -->
<a class="btn" onclick="addToCart()">Buy</a>

<!-- An action on the page: a button -->
<button type="button" class="btn">Buy</button>

<!-- Navigation to another page: a link with href -->
<a class="btn" href="/cart">Buy</a>

<script>
await page.getByRole('button', { name: 'Buy' }).click();
</script>
🔧 Розбір синтаксису: onclick="addToCart()" вішає обробник кліку, але не дає ні ролі, ні фокусу з клавіатури. type="button" на <button> не дає йому сабмітити форму, в якій він стоїть. href="/cart" — саме той атрибут, який перетворює <a> на роль link.
🔧 Syntax breakdown: onclick="addToCart()" attaches a click handler but gives neither a role nor keyboard focus. type="button" on a <button> stops it from submitting the form it sits in. href="/cart" is exactly the attribute that turns <a> into the link role.
💡 Простими словами: <a> без href — не посилання ні для скрінрідера, ні для клавіатури, ні для getByRole. Тест тут правий, а розмітка — ні.
💡 In plain words: An <a> without href is not a link for a screen reader, a keyboard or getByRole. The test is right here, and the markup is wrong.
🗣 Як читати вголос Тут справа не в Playwright, а в розмітці. Тег a стає посиланням тільки з атрибутом href, без нього це просто контейнер з текстом і роллю generic. Мишею такий елемент клікається, бо на ньому висить onclick, але з клавіатури до нього не дійти, і скрінрідер його не бачить. Тому я не міняю локатор на клас, щоб тест позеленів, а заводжу баг: або це кнопка з type button, якщо дія на сторінці, або справжнє посилання з href, якщо це перехід. Роль я перевіряю в панелі Accessibility, це займає буквально кілька секунд і одразу дає аргумент для баг-репорту.
🗣 How to say it out loud This is not about Playwright, it is about the markup. An a tag becomes a link only when it has href; without it, it is a plain text container with the generic role. A mouse can click it because there is an onclick on it, but a keyboard cannot reach it and a screen reader does not see it. So I do not switch the locator to a class just to turn the test green — I raise a bug: it is either a button with type button when the action stays on the page, or a real link with href when it navigates. I confirm the role in the Accessibility pane, which takes seconds.
7 Як ранжувати локатори від найкращого до найгіршого і чому саме так? How do you rank locators from best to worst, and why in that order?
HTMLHTML Middle ▶

Що це. Принцип простий: чим ближче локатор до того, як елемент знаходить людина, тим краще. Для кнопки «Оформити замовлення» порядок такий: роль і доступне ім'я (getByRole('button', { name: ... })) → видимий текст → data-testid, доданий спеціально для тестів → клас із верстки на кшталт .btn-primary → ланцюжок за позицією div > div:nth-child(3) > button.

What it is. The rule is simple: the closer a locator sits to how a person finds the element, the better. For a "Place order" button the order is: role and accessible name (getByRole('button', { name: ... })) → visible text → a data-testid added specifically for tests → a styling class like .btn-primary → a positional chain div > div:nth-child(3) > button.

Навіщо саме так. Користувач бачить роль і підпис, а не класи та вкладеність. Тому верхні локатори переживають рефакторинг верстки, зміну CSS-фреймворку і перестановку обгорток, а нижні ламаються від кожної з цих змін. Паралельно роль з ім'ям ще й перевіряє доступність: якщо кнопка стала <div>, тест впаде, і це правильно. Test id стоїть посередині: стабільний, але нічого не каже про те, що бачить людина.

Why this order. A user sees the role and the label, not classes and nesting. So the top locators survive markup refactors, a switch of CSS framework and reshuffled wrappers, while the bottom ones break on each of those. A role with a name also checks accessibility along the way: if the button turns into a <div>, the test fails, and rightly so. A test id sits in the middle: stable, but silent about what a person actually sees.

Приклад. Дизайнер перейшов з Bootstrap на Tailwind: .btn-primary зник, обгорток стало на дві більше. Тести на getByRole лишилися зеленими, тести на класах і nth-child почервоніли, хоча для користувача не змінилось нічого. Це і є вимірювання стійкості: скільки нешкідливих змін локатор переживає.

Example. The designer moved from Bootstrap to Tailwind: .btn-primary disappeared and two more wrappers appeared. The getByRole tests stayed green, the class and nth-child tests went red, even though nothing changed for the user. That is how you measure robustness: how many harmless changes a locator survives.

Підводні камені. Текст залежить від локалізації: у мультимовному застосунку getByText('Оформити замовлення') падає на англійській версії, тож або фіксуй мову тестового оточення, або бери ключі перекладу. Роль без name часто знаходить кілька елементів і ламає strict mode. А XPath сам по собі не гірший за CSS — погана саме прив'язка до позиції.

Pitfalls. Text depends on localisation: in a multilingual app getByText('Place order') fails on another language, so either pin the test environment's language or use translation keys. A role without name often matches several elements and breaks strict mode. And XPath is not worse than CSS by itself — it is the positional binding that is bad.

Уточнююче питання. «Що робити, якщо ролі немає взагалі?» — тоді це або дефект доступності, або справді декоративний контейнер, і тут доречний data-testid.

Follow-up question. "What if there is no role at all?" Then it is either an accessibility defect or a purely decorative container, and that is where data-testid fits.

Одна кнопка — п'ять локаторів
One button, five locators
// 1. Роль + доступне ім'я — як бачить людина і скрінрідер
page.getByRole('button', { name: 'Оформити замовлення' });
// 2. Видимий текст
page.getByText('Оформити замовлення');
// 3. Контракт з розробниками
page.getByTestId('checkout-submit');
// 4. Клас із верстки — зміниться з дизайном
page.locator('.btn-primary');
// 5. Позиція в дереві — ламається від будь-якої обгортки
page.locator('div > div:nth-child(3) > button');
// 1. Role + accessible name — how a person and a screen reader see it
page.getByRole('button', { name: 'Place order' });
// 2. Visible text
page.getByText('Place order');
// 3. A contract with developers
page.getByTestId('checkout-submit');
// 4. A styling class — changes with the design
page.locator('.btn-primary');
// 5. Position in the tree — breaks on any extra wrapper
page.locator('div > div:nth-child(3) > button');
🔧 Розбір синтаксису: getByRole, getByText, getByTestId — вбудовані локатори Playwright; getByTestId за замовчуванням читає атрибут data-testid, а змінити його можна через testIdAttribute у конфігу. page.locator(css) приймає довільний CSS; > — прямий нащадок, :nth-child(3) — третій за рахунком серед сусідів.
🔧 Syntax breakdown: getByRole, getByText and getByTestId are Playwright's built-in locators; getByTestId reads the data-testid attribute by default, and testIdAttribute in the config changes that. page.locator(css) takes any CSS; > means a direct child and :nth-child(3) means third among its siblings.
💡 Простими словами: Шукай елемент так, як його шукала б людина: за роллю і підписом. Що нижче в списку, то частіше тест падатиме без реального бага.
💡 In plain words: Find an element the way a person would: by role and label. The lower on the list, the more often a test fails without a real bug.
🗣 Як читати вголос Я ранжую локатори за тим, наскільки вони близькі до того, як користувач знаходить елемент. На першому місці роль із доступним ім'ям, потім видимий текст, далі test id як домовленість з розробниками, потім класи, і в самому низу ланцюжки з nth-child. Логіка в тому, що людина не бачить класів і вкладеності, тому зміни верстки не повинні ламати тест. Колись у нас переїхали на інший CSS-фреймворк, і впали лише тести на класах, хоча для користувача все лишилось як було. Роль ще й безкоштовно перевіряє доступність, це приємний бонус.
🗣 How to say it out loud I rank locators by how close they are to the way a user finds the element. At the top is a role with an accessible name, then visible text, then a test id as an agreement with developers, then classes, and at the very bottom chains with nth-child. The reasoning is that a person never sees classes or nesting, so a layout change should not break the test. We once moved to a different CSS framework and only the class-based tests went red, while nothing changed for the user. A role also checks accessibility for free, which is a nice bonus.
8 Що насправді не так із локатором div > div:nth-child(3) > span? What is actually wrong with the locator div > div:nth-child(3) > span?
HTMLHTML Senior ▶

Що це. Такий локатор описує позицію вузла в дереві, а не його сенс: «спан у третьому діві всередині якогось діва». Проблема не в швидкості і не в тому, що це CSS, а в тому, який сигнал дає тест. Він червоніє від безпечного рефакторингу — додали обгортку для flex, переставили банер, — і мовчить, коли зламалася поведінка, бо на тій самій позиції тепер стоїть інший елемент з іншим текстом.

What it is. This locator describes a node's position in the tree, not its meaning: "a span in the third div inside some div". The problem is not speed and not the fact that it is CSS — it is the signal the test gives. It goes red on a safe refactor, such as an extra wrapper for flex or a moved banner, and stays silent when behaviour breaks, because a different element with different text now sits in the same position.

Навіщо це розуміти на senior-рівні. Ціна крихкого локатора — довіра до сюїти. Коли кожен реліз дає десяток «падінь через верстку», команда звикає перезапускати і ігнорувати червоне, і справжня регресія проходить у прод. Стійкість вимірюється тим, скільки нешкідливих змін локатор переживає і чи падає він на шкідливих. Позиційний ланцюжок провалює обидві перевірки.

Why it matters at senior level. The cost of a brittle locator is trust in the suite. When every release brings a dozen "layout failures", the team gets used to rerunning and ignoring red, and a real regression slips into production. Robustness is measured by how many harmless changes a locator survives and whether it fails on harmful ones. A positional chain fails both checks.

Приклад. Тест перевіряв ціну в кошику через div > div:nth-child(3) > span. Маркетинг додав блок «Безкоштовна доставка» над підсумком, і тепер третій <div> — це рядок знижки. Тест бере «−50 ₴», звіряє з очікуваним форматом суми — і проходить, бо перевіряв лише, що текст схожий на гроші. Регресія в розрахунку підсумку пройшла повз.

Example. A test checked the cart total through div > div:nth-child(3) > span. Marketing added a "Free delivery" block above the summary, and the third <div> is now the discount row. The test reads "−$5", compares it with the expected money format and passes, because it only checked that the text looked like money. A regression in the total calculation went straight past it.

Як виправити. Прив'язатися до сенсу: знайти рядок за підписом «Разом» і взяти значення в межах нього, або попросити в розробників семантичну розмітку (<dl> з <dt>/<dd>) чи data-testid на підсумку. Паралельно перевіряй точне значення, а не формат: слабка перевірка маскує поганий локатор.

How to fix it. Bind to meaning: find the row by its "Total" label and read the value inside it, or ask developers for semantic markup (<dl> with <dt>/<dd>) or a data-testid on the total. Also assert the exact value, not the format: a weak assertion hides a bad locator.

Уточнююче питання. «А якщо легасі-сторінку не можна змінити?» — тоді локатор від найближчого стабільного якоря (заголовка, мітки) з filter({ hasText }), а сам ризик — у тест-план.

Follow-up question. "What if the legacy page cannot be changed?" Then anchor the locator to the nearest stable landmark (a heading, a label) with filter({ hasText }), and put the remaining risk into the test plan.

Позиція проти сенсу
Position versus meaning
// Крихко: після нового банера це вже рядок знижки
const total = page.locator('div > div:nth-child(3) > span');
await expect(total).toHaveText(/\d+ ₴/);   // слабка перевірка, пройде

// Стійко: рядок за підписом, значення всередині нього
const row = page.getByRole('row').filter({ hasText: 'Разом' });
await expect(row.getByRole('cell').last()).toHaveText('1 250 ₴');

// Або контракт з розробниками
await expect(page.getByTestId('cart-total')).toHaveText('1 250 ₴');
// Brittle: after the new banner this is the discount row
const total = page.locator('div > div:nth-child(3) > span');
await expect(total).toHaveText(/\$\d+/);   // weak assertion, will pass

// Robust: the row by its label, the value inside it
const row = page.getByRole('row').filter({ hasText: 'Total' });
await expect(row.getByRole('cell').last()).toHaveText('$125.00');

// Or a contract with developers
await expect(page.getByTestId('cart-total')).toHaveText('$125.00');
🔧 Розбір синтаксису: toHaveText(/\d+ ₴/) з регулярним виразом перевіряє лише формат, тому пропускає будь-яку суму. getByRole('row').filter({ hasText }) залишає тільки рядки таблиці, в тексті яких є «Разом»; getByRole('cell').last() бере останню комірку в ньому. Рядок у toHaveText('1 250 ₴') звіряє точне значення.
🔧 Syntax breakdown: toHaveText(/\$\d+/) with a regular expression checks only the format, so any amount passes. getByRole('row').filter({ hasText }) keeps only table rows whose text contains "Total"; getByRole('cell').last() takes the last cell in it. The string in toHaveText('$125.00') compares the exact value.
💡 Простими словами: Позиційний локатор ламається від нешкідливих змін і пропускає шкідливі. Прив'язуйся до того, що елемент означає, і перевіряй точне значення.
💡 In plain words: A positional locator breaks on harmless changes and misses harmful ones. Bind to what the element means and assert the exact value.
🗣 Як читати вголос Головна проблема не в тому, що це CSS чи що він повільний. Він описує, де лежить вузол, а не що це за вузол. Тому тест падає від будь-якої нової обгортки, а коли на третю позицію стане інший елемент, він може спокійно пройти. У мене був випадок, коли після нового банера тест на суму кошика почав читати рядок знижки і лишався зеленим, бо перевіряв тільки формат. Такі хибні падіння і хибні проходи поступово вбивають довіру команди до всієї сюїти. Тому я прив'язуюсь до підпису «Разом» або до test id і звіряю точну суму, а не лише її формат.
🗣 How to say it out loud The main problem is not that it is CSS or that it is slow. It describes where a node sits, not what the node is. So the test fails on every new wrapper, and when a different element lands in third position it can quietly pass. I had a case where, after a new banner, the cart total test started reading the discount row and stayed green, because it only checked the format. False failures and false passes like that kill trust in the suite. So I anchor to the Total label or to a test id and check the exact amount, not just its shape.
9 Коли data-testid — обґрунтований вибір, а не спосіб не думати про локатор? When is data-testid a justified choice rather than a way to avoid thinking about the locator?
HTMLHTML Senior ▶

Що це. data-testid — атрибут, який розробники додають лише заради тестів. Він стабільний, бо не залежить ні від тексту, ні від стилів, але не доводить нічого, крім власного існування. Локатор за роллю й ім'ям паралельно доводить, що елемент доступний людині; test id — ні. Тому test id — запасний вихід для того, що ролі не має, а не заміна ролі там, де вона є.

What it is. data-testid is an attribute developers add purely for tests. It is stable because it depends on neither text nor styles, but it proves nothing beyond its own existence. A role-and-name locator also proves the element is reachable for a person; a test id does not. So a test id is the fallback for things that have no role, not a replacement for a role where one exists.

Коли він доречний. Контейнери без ролі й тексту: обгортка віджета, полотно графіка (<canvas>), рядок таблиці, що складається лише з іконок, скелетон завантаження. Також динамічний текст, який змінюється щоразу (ціна з A/B-тесту, згенерований номер замовлення), і багатомовні продукти, де прив'язка до тексту множить підтримку. Ще один кейс — однакові кнопки в списку, коли ти шукаєш картку за data-testid="order-1042", а кнопку вже за роллю всередині неї.

When it fits. Containers with no role and no text: a widget wrapper, a chart's <canvas>, a table row made of icons only, a loading skeleton. Also dynamic text that changes every time (an A/B-tested price, a generated order number), and multilingual products where binding to text multiplies maintenance. One more case is identical buttons in a list: you find the card by data-testid="order-1042" and then the button inside it by role.

Приклад компромісу. У дашборді є графік виручки на <canvas>. Роль у нього в кращому разі img з підписом, а перевірити треба, що він відмалювався після фільтра. getByTestId('revenue-chart') плюс скриншотне порівняння — розумне рішення. А ось кнопка «Застосувати фільтр» має чудову роль і ім'я, тож test id на ній — зайвий.

A worked trade-off. A dashboard has a revenue chart on <canvas>. At best its role is img with a label, and you need to check it re-rendered after a filter. getByTestId('revenue-chart') plus a screenshot comparison is a sensible choice. The "Apply filter" button, though, has a perfectly good role and name, so a test id on it is redundant.

Підводні камені. Коли командна політика «усюди test id», тести зеленіють на кнопках-<div>, до яких не дійти з клавіатури, і сюїта перестає ловити регресії доступності. Test id також дублюється в циклах без унікального суфікса і може витікати в прод-розмітку — його іноді вирізають на збірці, і тоді тести на стенді з прод-білдом падають. Іменування варто зафіксувати домовленістю: сторінка-блок-елемент.

Pitfalls. With a team policy of "test ids everywhere", tests stay green on <div> buttons that a keyboard cannot reach, and the suite stops catching accessibility regressions. Test ids also get duplicated in loops without a unique suffix, and they can leak into production markup — some builds strip them, and then tests against a production build fail. Fix the naming by agreement, for example page-block-element.

Уточнююче питання. «Як переконати розробників додавати test id?» — показати конкретні нестабільні тести й домовитись про атрибут як про частину контракту компонента.

Follow-up question. "How do you get developers to add test ids?" Show them concrete flaky tests and agree that the attribute is part of the component's contract.

Test id там, де ролі немає; роль — усередині
Test id where there is no role; role inside it
// Картка замовлення не має ролі — беремо test id
const order = page.getByTestId('order-1042');
// Кнопка всередині має роль і ім'я — test id не потрібен
await order.getByRole('button', { name: 'Скасувати' }).click();

// Canvas-графік: лише test id + візуальна перевірка
await expect(page.getByTestId('revenue-chart'))
  .toHaveScreenshot('revenue-after-filter.png');

// playwright.config.ts, якщо в проєкті інший атрибут
// use: { testIdAttribute: 'data-qa' }
// The order card has no role — use the test id
const order = page.getByTestId('order-1042');
// The button inside has a role and a name — no test id needed
await order.getByRole('button', { name: 'Cancel' }).click();

// Canvas chart: test id plus a visual check only
await expect(page.getByTestId('revenue-chart'))
  .toHaveScreenshot('revenue-after-filter.png');

// playwright.config.ts, if the project uses another attribute
// use: { testIdAttribute: 'data-qa' }
🔧 Розбір синтаксису: getByTestId('order-1042') знаходить елемент з data-testid="order-1042", а ланцюжок order.getByRole(...) шукає кнопку лише всередині цієї картки. toHaveScreenshot(name) порівнює знімок елемента з еталонним файлом. testIdAttribute у секції use конфігу змінює атрибут, який читає getByTestId.
🔧 Syntax breakdown: getByTestId('order-1042') finds the element with data-testid="order-1042", and the chain order.getByRole(...) looks for the button only inside that card. toHaveScreenshot(name) compares an element snapshot with a baseline file. testIdAttribute in the config's use section changes the attribute getByTestId reads.
💡 Простими словами: Test id — для того, що не має ролі й підпису. Де роль є, вона одночасно шукає елемент і перевіряє, що людина до нього дістанеться.
💡 In plain words: A test id is for things with no role and no label. Where a role exists, it both finds the element and checks that a person can reach it.
🗣 Як читати вголос Я сприймаю test id як запасний вихід, а не як стандарт. Локатор за роллю і назвою заодно перевіряє, що елемент доступний людині, а test id доводить тільки те, що хтось поставив атрибут. Тому я беру його там, де ролі справді немає: canvas-графік, контейнер картки, скелетон, або де текст динамічний чи залежить від мови. Типовий мій патерн — знайти картку замовлення за test id, а кнопку всередині вже за роллю. Коли ж команда ставить test id на все підряд, сюїта перестає бачити кнопки-діви, до яких не дійти з клавіатури.
🗣 How to say it out loud I treat a test id as a fallback, not a default. A role and name locator also verifies that a person can reach the element, while a test id only proves someone added an attribute. So I use it where there really is no role: a canvas chart, a card container, a skeleton, or text that is dynamic or depends on the language. A typical pattern of mine is to find the order card by test id and then the button inside it by role. When a team puts test ids on everything, the suite stops noticing div buttons that a keyboard cannot reach.
10 Як тестувати елементи всередині Shadow DOM та iframe? How do you test elements inside Shadow DOM and iframes?
HTMLHTML Middle ▶

Що це. Shadow DOM — ізольоване піддерево веб-компонента: його стилі й розмітка не змішуються зі сторінкою, а document.querySelector всередину не заглядає. Iframe — окремий документ зі своїм DOM, своєю історією і часто іншим доменом. В Elements shadow-корінь видно як вузол #shadow-root (open), а вміст iframe — як окремий #document.

What it is. Shadow DOM is a web component's isolated subtree: its styles and markup do not mix with the page, and document.querySelector does not look inside it. An iframe is a separate document with its own DOM, its own history and often a different domain. In Elements a shadow root shows up as a #shadow-root (open) node, and an iframe's content as a separate #document.

Як це працює в інструментах. Локатори Playwright за замовчуванням проходять крізь відкриті shadow-корені, тож getByRole знаходить кнопку всередині веб-компонента без жодних хитрощів. Закриті корені (mode: 'closed') недоступні — туди не дістається навіть скрипт сторінки. XPath крізь shadow DOM не працює. Для iframe потрібно явно зайти у фрейм: page.frameLocator(...) або locator.contentFrame(). У Selenium — driver.switchTo().frame(...) і назад defaultContent(), а для shadow DOM — getShadowRoot().

How tools handle it. Playwright locators pierce open shadow roots by default, so getByRole finds a button inside a web component with no tricks. Closed roots (mode: 'closed') are off limits — even the page's own scripts cannot reach in. XPath does not pierce shadow DOM. For an iframe you have to step into the frame explicitly: page.frameLocator(...) or locator.contentFrame(). In Selenium it is driver.switchTo().frame(...) and back with defaultContent(), and getShadowRoot() for shadow DOM.

Приклад. Платіжна форма Stripe чи іншого провайдера живе в iframe з іншого домену. Тест заповнює номер картки через frameLocator; з консолі сторінки до цих полів дістатися не можна через same-origin policy — і це правильно, бо так захищені дані картки. Кнопка «Прийняти cookies» у банері на веб-компоненті знаходиться звичайним getByRole.

Example. A Stripe-style payment form lives in an iframe from another domain. The test fills the card number through frameLocator; from the page's console those fields are unreachable because of the same-origin policy, which is right, since that is what protects the card data. An "Accept cookies" button in a banner built as a web component is found with a plain getByRole.

Підводні камені. Фрейм може підвантажитись пізніше за сторінку, тож потрібні локатори, які самі чекають, а не одноразовий пошук. У консолі DevTools обирай потрібний контекст у списку «top», інакше document вказує на головну сторінку. Скриншотні тести часто не бачать стилів веб-компонентів, якщо їх підвантажують асинхронно.

Pitfalls. A frame may load later than the page, so you need locators that wait on their own rather than a one-off lookup. In the DevTools console, pick the right context in the "top" dropdown, otherwise document points to the main page. Screenshot tests often miss web component styles when they are loaded asynchronously.

Уточнююче питання. «Як перевірити крос-доменний iframe?» — лише через інструмент автоматизації або ручний вибір контексту в DevTools; JavaScript сторінки туди доступу не має.

Follow-up question. "How do you test a cross-origin iframe?" Only through an automation tool or by picking the context by hand in DevTools; the page's JavaScript has no access to it.

Iframe і shadow DOM у Playwright та Selenium
Iframe and shadow DOM in Playwright and Selenium
// Playwright: open shadow roots are pierced automatically
await page.getByRole('button', { name: 'Accept' }).click();

// Playwright: step into an iframe explicitly
const pay = page.frameLocator('iframe[title="Secure card payment"]');
await pay.getByLabel('Card number').fill('4242 4242 4242 4242');
// same thing via a locator
const frame = page.locator('#checkout-frame').contentFrame();
await frame.getByRole('button', { name: 'Pay' }).click();

// Selenium (Java)
// driver.switchTo().frame("checkout-frame");
// driver.switchTo().defaultContent();
// host.getShadowRoot().findElement(By.cssSelector("button"));
🔧 Розбір синтаксису: frameLocator(selector) повертає об'єкт, через який локатори шукають уже всередині документа фрейму; contentFrame() робить те саме з наявного локатора елемента <iframe>. Селектор iframe[title="..."] — атрибутний CSS-селектор. У Selenium switchTo().frame перемикає контекст драйвера, defaultContent() повертає на головну сторінку, а getShadowRoot() відкриває відкритий shadow-корінь елемента-хоста.
🔧 Syntax breakdown: frameLocator(selector) returns an object whose locators search inside the frame's document; contentFrame() does the same from an existing locator of an <iframe> element. iframe[title="..."] is a CSS attribute selector. In Selenium switchTo().frame switches the driver's context, defaultContent() returns to the main page, and getShadowRoot() opens the open shadow root of a host element.
💡 Простими словами: Відкритий shadow DOM Playwright проходить сам, у iframe треба зайти явно. Закритий shadow-корінь і чужий домен з коду сторінки недосяжні.
💡 In plain words: Playwright walks through open shadow DOM by itself, but an iframe has to be entered explicitly. A closed shadow root and a foreign domain are unreachable from page code.
🗣 Як читати вголос Я розрізняю два випадки. Shadow DOM — це інкапсульоване піддерево веб-компонента, і Playwright проходить крізь відкриті корені сам, тож мої звичайні локатори за роллю там працюють. Закриті корені недоступні, і XPath теж крізь тінь не бачить. Iframe — це окремий документ, туди я заходжу явно через frameLocator або contentFrame, а в Selenium перемикаю контекст на фрейм і потім повертаюсь назад. Класичний приклад — платіжна форма з іншого домену: з консолі сторінки до неї не дістатися, і так і має бути, бо це захист даних картки.
🗣 How to say it out loud I separate two cases. Shadow DOM is a web component's encapsulated subtree, and Playwright pierces open roots by itself, so my usual role locators work there. Closed roots are out of reach, and XPath cannot see through the shadow either. An iframe is a separate document, so I step into it explicitly with frameLocator or contentFrame, and in Selenium I switch the context to the frame and then back again. The classic example is a payment form from another domain: the page's console cannot touch it, and that is how it should be, because it protects the card data.
▶ 2 8
11 Як працює специфічність CSS-селекторів і чому стиль «не застосовується»? How does CSS selector specificity work, and why does a style "not apply"?
CSSCSS Junior ▶

Що це. Коли кілька правил задають одну властивість одному елементу, браузер обирає переможця за каскадом: спершу важливість (!important), потім специфічність селектора, і лише за рівності — порядок у файлі (пізніше правило виграє). Специфічність рахують трьома числами: id, потім класи/атрибути/псевдокласи, потім теги/псевдоелементи. Селектор #checkout .btn має вагу 1-1-0 і б'є .page .btn.primary з вагою 0-3-0, скільки б класів там не було.

What it is. When several rules set the same property on one element, the browser picks a winner through the cascade: importance first (!important), then selector specificity, and only on a tie the source order (the later rule wins). Specificity is counted as three numbers: ids, then classes/attributes/pseudo-classes, then type selectors/pseudo-elements. The selector #checkout .btn weighs 1-1-0 and beats .page .btn.primary at 0-3-0, no matter how many classes you stack.

Навіщо. Баг «кнопка мала стати синьою, а лишилась зеленою» майже завжди пояснюється каскадом, а не тим, що CSS «не завантажився». Тестер, який уміє це прочитати, пише в репорті конкретне правило-переможця і файл, а розробник фіксить за хвилину.

Why it matters. A bug like "the button should turn blue but stays green" is nearly always explained by the cascade, not by CSS "failing to load". A tester who can read it puts the winning rule and its file into the report, and the developer fixes it in a minute.

Приклад. Обери елемент в Elements і відкрий панель Styles: правила відсортовані від найсильнішого, а програшні властивості закреслені. Вкладка Computed показує фінальне значення і, якщо розгорнути властивість, звідки воно прийшло. Інлайн-стиль в атрибуті style сильніший за будь-який селектор, а !important перебиває навіть його.

Example. Select the element in Elements and open the Styles pane: rules are ordered from strongest to weakest, and overridden declarations are struck through. The Computed tab shows the final value and, once you expand the property, where it came from. An inline value in the style attribute outranks any selector, and !important overrides even that.

Підводні камені. Жовтий трикутник біля властивості в Styles означає невалідне значення — браузер його просто ігнорує, і це теж виглядає як «стиль не застосувався». Псевдоклас :where() має нульову специфічність, а :is() бере вагу найсильнішого аргументу, тож однаково виглядаючі селектори можуть важити по-різному.

Pitfalls. A yellow warning triangle next to a declaration in Styles means an invalid value — the browser just drops it, which also looks like "the style did not apply". The :where() pseudo-class has zero specificity, while :is() takes the weight of its strongest argument, so selectors that look alike can weigh differently.

Уточнююче питання. «Чому розробники не люблять !important?» Бо його можна перебити лише іншим !important з вищою специфічністю — починається гонка, і стилі стають непередбачуваними.

Follow-up question. "Why do developers dislike !important?" Because only another !important with higher specificity can beat it — that starts an arms race and the styles become unpredictable.

Три правила на одну кнопку — хто переможе
Three rules on one button — which one wins
/* specificity: 1 id, 1 class, 0 types -> 1-1-0 */
#checkout .btn { background: green; }

/* 0-3-0: more classes, still loses to an id */
.page .btn.primary { background: blue; }

/* 0-1-1, but !important beats both rules above */
button.btn { background: red !important; }
🔧 Розбір синтаксису: /* ... */ — коментар у CSS. #checkout — селектор за id, .btn — за класом, button — за тегом; пробіл між частинами означає «нащадок усередині». .btn.primary без пробілу — один елемент з обома класами. Позначка !important після значення піднімає декларацію над звичайними правилами, тож кнопка буде червоною.
🔧 Syntax breakdown: /* ... */ is a CSS comment. #checkout selects by id, .btn by class, button by tag; a space between parts means "a descendant inside". .btn.primary with no space is one element carrying both classes. The !important flag after a value lifts the declaration above normal rules, so the button ends up red.
💡 Простими словами: Стиль не зник — його перебило сильніше правило. Styles покаже закреслену властивість, а Computed — хто переміг.
💡 In plain words: The style did not vanish — a stronger rule beat it. Styles shows the struck-through declaration and Computed shows who won.
🗣 Як читати вголос Коли мені кажуть, що стиль не застосувався, я одразу відкриваю Styles для цього елемента. Там закреслена властивість і поруч правило, яке її перебило. Далі просто рахую вагу: айді важить більше за будь-яку кількість класів, класи більше за теги, а при рівній вазі виграє те правило, що йде пізніше у файлі. Інлайн-стиль і important стоять окремо, вони сильніші. Ще звертаю увагу на жовтий трикутник — це невалідне значення, яке браузер тихо викинув. У баг-репорт я пишу, яке правило і з якого файлу виграло, тоді розробнику не треба нічого шукати.
🗣 How to say it out loud When someone tells me a style did not apply, the first thing I open is the Styles pane for that element. The overridden declaration is struck through, and the rule that beat it sits right above. Then I just count the weight: an id outweighs any number of classes, classes outweigh tags, and on equal weight the rule later in the file wins. Inline styles and important sit on their own level above that. I also watch for the yellow triangle, which means an invalid value the browser quietly threw away. In the bug report I name the winning rule and its file, so the developer does not have to hunt for it.
12 display: none, visibility: hidden і opacity: 0 — у чому різниця для користувача, скрінрідера й автотесту? display: none, visibility: hidden and opacity: 0 — how do they differ for the user, a screen reader and an automated test?
CSSCSS Middle ▶

Що це. Три способи «сховати» елемент поводяться по-різному. display: none прибирає його з розкладки повністю: місця він не займає, розмір нульовий, фокусу й кліку немає. visibility: hidden робить елемент невидимим, але місце під ним лишається. opacity: 0 лише робить його прозорим: він займає місце, отримує фокус з клавіатури і ловить кліки.

What it is. Three ways to "hide" an element behave differently. display: none removes it from layout entirely: no space, zero size, no focus and no clicks. visibility: hidden makes the element invisible, but its space stays reserved. opacity: 0 only makes it transparent: it keeps its space, still takes keyboard focus and still catches clicks.

Навіщо. Для скрінрідера перші два варіанти прибирають елемент з дерева доступності, а прозорий елемент він прочитає вголос. Для Playwright елемент видимий, якщо в нього непорожній bounding box і немає visibility: hidden; прозорість не враховується. Тож toBeHidden() пройде для перших двох і впаде для третього.

Why it matters. For a screen reader the first two remove the element from the accessibility tree, while a transparent element is still read aloud. For Playwright an element is visible when it has a non-empty bounding box and no visibility: hidden; opacity is ignored. So toBeHidden() passes for the first two and fails for the third.

Приклад. Тост «Збережено» після закриття має зникнути. Розробник анімує його до opacity: 0 і забуває прибрати: користувач нічого не бачить, але невидимий тост перекриває кнопку під собою і забирає кліки. Перевірка toBeVisible() тут вводить в оману, тому для такого випадку додаєш toHaveCSS('opacity', '0') і перевіряєш, що кнопка під ним натискається.

Example. A "Saved" toast should disappear once closed. The developer animates it to opacity: 0 and never removes it: the user sees nothing, but the invisible toast covers the button beneath it and swallows clicks. A toBeVisible() check is misleading here, so you add toHaveCSS('opacity', '0') and verify the button underneath can be clicked.

Підводні камені. Елемент нульового розміру (порожній div без висоти) для Playwright теж невидимий, хоча display у нього звичайний. А елемент, винесений за межі екрана через left: -9999px, формально видимий. Ще нюанс: getByRole за замовчуванням пропускає елементи, приховані від дерева доступності.

Pitfalls. A zero-size element (an empty div with no height) is also not visible to Playwright, even though its display is normal. An element pushed off screen with left: -9999px is formally visible. One more detail: getByRole skips elements hidden from the accessibility tree by default.

Уточнююче питання. «Чим toBeHidden() відрізняється від not.toBeVisible()?» По суті нічим, обидві ретраяться; toBeHidden() також проходить, коли елемента в DOM узагалі немає.

Follow-up question. "How is toBeHidden() different from not.toBeVisible()?" Practically not at all, both retry; toBeHidden() also passes when the element is not in the DOM at all.

Три способи сховати елемент
Three ways to hide an element
.toast--closed  { display: none; }       /* no box, no space */
.tooltip--idle  { visibility: hidden; }  /* space kept, not visible */
.banner--faded  { opacity: 0; }          /* transparent, still clickable */
Що скаже Playwright
What Playwright says
await expect(page.locator('.toast--closed')).toBeHidden();
await expect(page.locator('.tooltip--idle')).toBeHidden();

// opacity: 0 для Playwright — все ще видимий елемент
await expect(page.locator('.banner--faded')).toBeVisible();
await expect(page.locator('.banner--faded')).toHaveCSS('opacity', '0');
await expect(page.locator('.toast--closed')).toBeHidden();
await expect(page.locator('.tooltip--idle')).toBeHidden();

// opacity: 0 is still a visible element for Playwright
await expect(page.locator('.banner--faded')).toBeVisible();
await expect(page.locator('.banner--faded')).toHaveCSS('opacity', '0');
🔧 Розбір синтаксису: У CSS кожен рядок — селектор класу і одна декларація у фігурних дужках. У JS page.locator(...) описує, як знайти елемент, а expect(locator) з методами toBeHidden(), toBeVisible() і toHaveCSS(назва, значення) — це web-first перевірки, які самі повторюються до таймауту. await потрібен, бо кожна перевірка асинхронна.
🔧 Syntax breakdown: In the CSS each line is a class selector with one declaration in braces. In the JS page.locator(...) describes how to find the element, and expect(locator) with toBeHidden(), toBeVisible() and toHaveCSS(name, value) are web-first assertions that retry on their own until the timeout. await is needed because every assertion is asynchronous.
💡 Простими словами: Прозорий не означає прихований: opacity: 0 бачить і скрінрідер, і миша, і Playwright.
💡 In plain words: Transparent is not hidden: a screen reader, the mouse and Playwright all still see an element with opacity: 0.
🗣 Як читати вголос Я розрізняю три випадки. Дисплей нон прибирає елемент з розкладки зовсім, він не займає місця і не клікається. Візібіліті хіден ховає, але місце лишає. А нульова прозорість лише робить елемент невидимим для ока, проте він ловить кліки і фокус, і скрінрідер його прочитає. Для Playwright важливо, що прозорий елемент рахується видимим, тому перевірка на прихованість на ньому впаде. Я якось ловив саме такий баг: закритий тост став прозорим, але лишився зверху і блокував кнопку під собою. Тому в таких місцях я окремо перевіряю значення опасіті і що елемент під ним реально натискається.
🗣 How to say it out loud I separate three cases. Display none takes the element out of layout completely, so it has no space and cannot be clicked. Visibility hidden hides it but keeps the space. Zero opacity only hides it from the eye, while it still catches clicks and focus and a screen reader will read it. For Playwright the key detail is that a transparent element counts as visible, so a to-be-hidden check on it fails. I once caught exactly that bug: a closed toast went transparent but stayed on top and blocked the button under it. So in places like that I check the opacity value separately and confirm the element underneath really clicks.
13 Що таке box model і як box-sizing впливає на розміри та перекриття елементів? What is the box model, and how does box-sizing affect element sizes and overlap?
CSSCSS Junior ▶

Що це. Кожен елемент на сторінці — прямокутник з чотирьох шарів: вміст (content), внутрішній відступ (padding), рамка (border) і зовнішній відступ (margin). Властивість box-sizing вирішує, що означає width. За замовчуванням content-box: ширина — лише вміст, а padding і border додаються зверху. З border-box ширина включає padding і border, і верстати сітки значно простіше.

What it is. Every element on the page is a rectangle of four layers: the content, the padding inside, the border, and the margin outside. The box-sizing property decides what width means. The default content-box counts only the content, and padding and border are added on top. With border-box the width includes padding and border, which makes grids much easier to build.

Навіщо. Багато багів верстки — це арифметика box model: дві колонки по 50% з padding на content-box не влазять в рядок і друга падає вниз, поле вводу вилазить за картку, текст кнопки обрізається. Коли знаєш модель, ти не пишеш «щось поїхало», а показуєш, яка саме сума не зійшлася.

Why it matters. Many layout bugs are box model arithmetic: two 50% columns with padding under content-box no longer fit in one row and the second drops down, an input pokes out of its card, button text gets clipped. When you know the model you stop writing "something shifted" and show which sum did not add up.

Приклад. В Elements обери елемент і прокрути Styles донизу або відкрий Computed: там схема з кольоровими прямокутниками margin, border, padding і content з точними числами. Наведення на вузол у дереві підсвічує ці шари прямо на сторінці: вміст блакитним, padding зеленим, margin помаранчевим.

Example. Select an element in Elements and scroll the Styles pane down or open Computed: there is a diagram of nested margin, border, padding and content boxes with exact numbers. Hovering a node in the tree highlights those layers on the page itself: content in blue, padding in green, margin in orange.

Підводні камені. Вертикальні margin двох сусідніх блоків схлопуються: 20px і 30px дають відступ 30px, а не 50px. outline і box-shadow не займають місця в розкладці, тож можуть накладатися на сусідів. Метод boundingBox() у Playwright повертає рамку елемента по border, без margin, — пам'ятай про це, коли перевіряєш розміри в тесті.

Pitfalls. The vertical margins of two adjacent blocks collapse: 20px and 30px give a 30px gap, not 50px. outline and box-shadow take no space in layout, so they can overlap neighbours. Playwright's boundingBox() returns the element's border box, without the margin — keep that in mind when a test checks sizes.

Уточнююче питання. «Чому в багатьох проєктах є правило * { box-sizing: border-box; }?» Бо з ним задана ширина — це реальна ширина на екрані, і padding більше не ламає розрахунки.

Follow-up question. "Why do so many projects have the rule * { box-sizing: border-box; }?" Because with it the declared width is the real width on screen, and padding no longer breaks the maths.

Одна ширина — два різні результати
One width, two different results
.card {
  width: 300px;
  padding: 20px;
  border: 2px solid #ccc;
  /* content-box (default): 300 + 2*20 + 2*2 = 344px on screen */
}

.card--fixed {
  box-sizing: border-box;
  /* 300px on screen, the content shrinks to 256px */
}
🔧 Розбір синтаксису: Усередині фігурних дужок — пари «властивість: значення;». padding: 20px дає відступ 20px з кожного з чотирьох боків, тому в розрахунку множимо на 2 по горизонталі. border: 2px solid #ccc — скорочений запис товщини, стилю й кольору рамки. .card--fixed — модифікатор у стилі BEM, який додає до картки інший спосіб рахувати ширину.
🔧 Syntax breakdown: Inside the braces are "property: value;" pairs. padding: 20px sets 20px on each of the four sides, which is why the horizontal sum multiplies by 2. border: 2px solid #ccc is shorthand for the border's width, style and colour. .card--fixed is a BEM-style modifier that gives the card a different way of counting its width.
💡 Простими словами: Ширина в CSS — це не завжди ширина на екрані. Відкрий Computed і подивись на схему з чотирьох прямокутників.
💡 In plain words: A CSS width is not always the width on screen. Open Computed and look at the diagram of four nested boxes.
🗣 Як читати вголос Я пояснюю box model як чотири вкладені прямокутники: вміст, падінг, рамка і маргін. Головне питання — що саме рахує ширина. За замовчуванням це лише вміст, і падінг з рамкою додаються зверху, тому картка на триста пікселів раптом займає триста сорок. З бордер-бокс ширина включає все, і сітки поводяться передбачувано. На практиці, коли колонка перестрибує на новий рядок або поле вилазить за край, я дивлюсь схему в Computed і рахую суму. Ще пам'ятаю про схлопування вертикальних маргінів і про те, що аутлайн і тінь не займають місця.
🗣 How to say it out loud I explain the box model as four nested rectangles: content, padding, border and margin. The key question is what the width actually counts. By default it is only the content, and padding and border are added on top, so a three-hundred-pixel card suddenly takes three hundred forty. With border-box the width includes everything and grids behave predictably. In practice, when a column jumps to the next row or an input sticks out past the edge, I look at the diagram in Computed and add up the numbers. I also remember that vertical margins collapse and that outlines and shadows take no space.
14 Навіщо потрібен meta viewport і що буде без нього? Why do you need the meta viewport tag, and what happens without it?
CSSCSS Junior ▶

Що це. Тег <meta name="viewport" content="width=device-width, initial-scale=1"> у <head> каже мобільному браузеру: бери ширину розкладки рівною ширині екрана пристрою в CSS-пікселях і не масштабуй сторінку на старті. Правильна відповідь у квізі — атрибут name="viewport".

What it is. The tag <meta name="viewport" content="width=device-width, initial-scale=1"> in <head> tells a mobile browser: make the layout width equal to the device screen width in CSS pixels and do not zoom the page at start. The correct quiz answer is the attribute name="viewport".

Навіщо. Без цього тегу мобільний браузер вважає, що перед ним десктопний сайт: верстає сторінку в умовних ~980px і зменшує все, щоб влізло в екран. Текст стає дрібним, кнопки крихітними, а медіазапити на кшталт (max-width: 600px) не спрацьовують, бо ширина розкладки ніколи не буває такою малою. Уся мобільна верстка просто ігнорується.

Why it matters. Without the tag a mobile browser assumes it has a desktop site: it lays the page out at a notional ~980px and scales everything down to fit the screen. Text gets tiny, buttons become minuscule, and media queries like (max-width: 600px) never match, because the layout width is never that small. The whole mobile stylesheet is simply ignored.

Приклад. Після рефакторингу шаблону хтось випадково видалив тег. На десктопі все як раніше, у DevTools у режимі пристрою сторінка виглядає як зменшена копія десктопу. У консолі на телефоні чи в емуляції document.documentElement.clientWidth повертає 980 замість 390 — ось доказ для репорту.

Example. During a template refactor someone accidentally deleted the tag. Desktop looks exactly as before, while DevTools device mode shows a shrunken copy of the desktop page. In the console on a phone or in emulation document.documentElement.clientWidth returns 980 instead of 390 — that is the evidence for the report.

Підводні камені. У Playwright звичайний viewport: { width: 390 } без isMobile: true цей тег не враховує: вікно просто вузьке, і тест не помітить, що тег зник. Опція isMobile вмикає врахування meta viewport, але у Firefox вона не підтримується. Окремо варто ловити user-scalable=no чи maximum-scale=1: заборона масштабування — це проблема доступності.

Pitfalls. In Playwright a plain viewport: { width: 390 } without isMobile: true ignores this tag: the window is just narrow, and the test will not notice the tag went missing. The isMobile option makes the browser honour meta viewport, but Firefox does not support it. Also flag user-scalable=no or maximum-scale=1: blocking zoom is an accessibility problem.

Уточнююче питання. «Як перевірити, що тег на місці, без телефона?» Відкрий Elements, знайди його в <head>, а тоді увімкни режим пристрою в DevTools і переконайся, що мобільна розкладка справді застосовується.

Follow-up question. "How do you check the tag is there without a phone?" Find it in <head> in Elements, then switch DevTools to device mode and confirm the mobile layout really applies.

Правильний тег і антипатерн
The right tag and an anti-pattern
<meta name="viewport" content="width=device-width, initial-scale=1">

<!-- anti-pattern: blocks pinch-zoom for people who need it -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
Тест, який помітить зниклий тег
A test that notices a missing tag
// isMobile: true змушує Chromium враховувати meta viewport
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });

test('розкладка бере ширину пристрою', async ({ page }) => {
  await page.goto('/');
  const width = await page.evaluate(() => document.documentElement.clientWidth);
  expect(width).toBe(390); // без тегу тут буде 980
});
// isMobile: true makes Chromium honour meta viewport
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });

test('layout takes the device width', async ({ page }) => {
  await page.goto('/');
  const width = await page.evaluate(() => document.documentElement.clientWidth);
  expect(width).toBe(390); // without the tag this is 980
});
🔧 Розбір синтаксису: content — список параметрів через кому: width=device-width прирівнює ширину розкладки до екрана, initial-scale=1 задає масштаб 100% на старті. У тесті test.use({...}) налаштовує контекст браузера для всіх тестів файлу, page.evaluate(() => ...) виконує функцію всередині сторінки і повертає результат у тест.
🔧 Syntax breakdown: content is a comma-separated list of parameters: width=device-width sets the layout width to the screen width, initial-scale=1 sets 100% zoom at start. In the test test.use({...}) configures the browser context for every test in the file, and page.evaluate(() => ...) runs a function inside the page and returns the result to the test.
💡 Простими словами: Немає meta viewport — телефон малює десктоп на 980px і зменшує його, а мобільні медіазапити мовчать.
💡 In plain words: No meta viewport means the phone draws a 980px desktop page and shrinks it, and the mobile media queries stay silent.
🗣 Як читати вголос Тег вьюпорт каже мобільному браузеру брати ширину сторінки рівною ширині екрана і не зменшувати її на старті. Якщо тегу немає, телефон вважає сайт десктопним, верстає його десь на дев'ятсот вісімдесят пікселів і стискає, тож текст дрібний, а мобільні медіазапити взагалі не вмикаються. Я перевіряю це просто: дивлюсь, чи тег є в хеді, і вмикаю режим пристрою в девтулз. В автотестах пам'ятаю, що вузьке вікно без опції ізМобайл цей тег не враховує, тому така перевірка пропустить регресію. І ще я завжди позначаю заборону масштабування як баг доступності.
🗣 How to say it out loud The viewport tag tells a mobile browser to make the page width equal to the screen width and not to zoom out at start. If the tag is missing, the phone treats the site as a desktop one, lays it out at roughly nine hundred eighty pixels and squeezes it, so the text is tiny and the mobile media queries never switch on. I check it simply: I see whether the tag is in the head and turn on device mode in DevTools. In automation I remember that a narrow window without the is-mobile option ignores this tag, so such a check would miss the regression. And I always report disabled zoom as an accessibility bug.
15 Що таке breakpoint і media queries і які ширини екрана варто перевіряти? What are breakpoints and media queries, and which screen widths should you test?
CSSCSS Junior ▶

Що це. Media query — умова в CSS, за якої застосовується блок стилів, найчастіше за шириною вікна: @media (min-width: 768px). Breakpoint — саме та ширина, на якій медіазапит перемикає розкладку: меню-бургер стає горизонтальним, одна колонка — трьома. Це не точка зупинки в дебагері і не місце, де сітка ламається завжди.

What it is. A media query is a CSS condition under which a block of styles applies, most often by window width: @media (min-width: 768px). A breakpoint is exactly the width at which a media query switches the layout: the hamburger menu turns horizontal, one column becomes three. It is not a debugger stop and not a place where the grid always breaks.

Навіщо. Найбільше багів адаптивності живе на межах: на 767px і 768px сторінка виглядає по-різному, і саме там дві розкладки можуть не стикуватися — меню вже сховалося, а бургер ще не з'явився. Тому перевіряти треба не «популярні телефони», а обидва боки кожного breakpoint: за 1px до і 1px після перемикання.

Why it matters. Most responsive bugs live on the edges: at 767px and 768px the page looks different, and that is where two layouts can fail to meet — the menu is already hidden and the hamburger has not appeared yet. So you test not "popular phones" but both sides of every breakpoint: 1px before and 1px after the switch.

Приклад. Увімкни режим пристрою (Ctrl+Shift+M), обери Responsive і в меню з трьома крапками — Show media queries. Над сторінкою з'являться кольорові смуги для кожного медіазапиту; клік по смузі виставляє точну ширину межі. Список breakpoints бери з CSS проєкту або з дизайн-системи, а не вигадуй.

Example. Turn on device mode (Ctrl+Shift+M), pick Responsive, and in the three-dot menu choose Show media queries. Coloured bars appear above the page for every media query; clicking a bar sets the exact width of that edge. Take the list of breakpoints from the project CSS or the design system rather than inventing it.

Підводні камені. На десктопі ширина для медіазапиту включає смугу прокрутки, тож лейаут може перемикатися не там, де показує clientWidth. Є запити не лише за шириною: orientation, hover, prefers-color-scheme, prefers-reduced-motion — їх теж можна емулювати у вкладці Rendering. Mobile-first код використовує min-width, desktop-first — max-width, і змішування обох породжує «дірки» між межами.

Pitfalls. On desktop the width used by media queries includes the scrollbar, so the layout may switch somewhere other than what clientWidth suggests. Queries are not only about width: orientation, hover, prefers-color-scheme, prefers-reduced-motion can be emulated too, in the Rendering tab. Mobile-first code uses min-width, desktop-first uses max-width, and mixing both creates gaps between the edges.

Уточнююче питання. «Як швидко дізнатися, чи спрацював запит?» У консолі виконай matchMedia('(min-width: 768px)').matches — отримаєш true або false для поточного вікна.

Follow-up question. "How do you quickly tell whether a query matched?" Run matchMedia('(min-width: 768px)').matches in the console — it gives true or false for the current window.

Mobile-first breakpoint
A mobile-first breakpoint
/* base styles: narrow screens */
.nav    { display: none; }
.burger { display: block; }

@media (min-width: 768px) {
  .nav    { display: flex; }
  .burger { display: none; }
}
Перевірка обох боків межі
Checking both sides of the edge
for (const width of [767, 768]) {
  test('навігація на ширині ' + width, async ({ page }) => {
    await page.setViewportSize({ width, height: 900 });
    await page.goto('/');
    // рівно один з двох елементів має бути видимим
    const desktop = width >= 768;
    await expect(page.locator('.nav')).toBeVisible({ visible: desktop });
    await expect(page.locator('.burger')).toBeVisible({ visible: !desktop });
  });
}
for (const width of [767, 768]) {
  test('navigation at width ' + width, async ({ page }) => {
    await page.setViewportSize({ width, height: 900 });
    await page.goto('/');
    // exactly one of the two must be visible
    const desktop = width >= 768;
    await expect(page.locator('.nav')).toBeVisible({ visible: desktop });
    await expect(page.locator('.burger')).toBeVisible({ visible: !desktop });
  });
}
🔧 Розбір синтаксису: @media (min-width: 768px) { ... } — правила всередині діють лише при ширині від 768px включно. У тесті цикл for...of створює окремий тест на кожну ширину, page.setViewportSize змінює розмір вікна, а опція { visible: false } у toBeVisible перевертає очікування, тож один рядок перевіряє обидва стани.
🔧 Syntax breakdown: @media (min-width: 768px) { ... } means the rules inside apply only at widths of 768px and above. In the test the for...of loop creates a separate test per width, page.setViewportSize resizes the window, and the { visible: false } option of toBeVisible flips the expectation, so one line covers both states.
💡 Простими словами: Breakpoint — ширина, де змінюється розкладка. Перевіряй за піксель до і піксель після, а не лише «на айфоні».
💡 In plain words: A breakpoint is the width where the layout changes. Test one pixel before and one pixel after it, not just "on an iPhone".
🗣 Як читати вголос Брейкпоінт для мене — це ширина, на якій медіазапит перемикає розкладку, наприклад з бургера на повне меню. Баги найчастіше сидять саме на межі, коли одна розкладка вже вимкнулась, а друга ще не ввімкнулась. Тому я беру список брейкпоінтів з CSS чи дизайн-системи і перевіряю кожен з двох боків, за піксель до і після. У девтулз вмикаю режим пристрою і показ медіазапитів, там клік по смузі одразу ставить потрібну ширину. І пам'ятаю, що на десктопі смуга прокрутки теж входить у цю ширину, тому межа може зсунутися на кілька пікселів.
🗣 How to say it out loud To me a breakpoint is the width at which a media query switches the layout, say from the hamburger to the full menu. Bugs most often sit right on that edge, when one layout has already switched off and the other has not switched on yet. So I take the list of breakpoints from the CSS or the design system and test each one from both sides, a pixel before and a pixel after. In DevTools I turn on device mode and the media query bars, where clicking a bar sets the exact width. And I remember that on desktop the scrollbar counts toward that width, so the edge can shift a little.
16 Чому зелений click() у тесті не доводить, що кнопка працює на телефоні? Why does a green click() in a test not prove the button works on a phone?
CSSCSS Middle ▶

Що це. Миша і палець генерують різні ланцюжки подій. На тачі спершу йдуть pointer- і touch-події (touchstart, touchend), і лише потім браузер синтезує сумісні mouse-події та click. Якщо обробник висить на touch-подіях, або хтось викликає preventDefault() на touchend, то десктопний click() у тесті пройде, а тап на телефоні — ні. Саме це правильна відповідь у квізі.

What it is. A mouse and a finger produce different chains of events. On touch, pointer and touch events come first (touchstart, touchend), and only then does the browser synthesise compatibility mouse events and the click. If the handler lives on touch events, or someone calls preventDefault() on touchend, a desktop click() in a test passes while a tap on the phone does not. That is the correct quiz answer.

Навіщо. Крім подій, на телефоні немає hover: меню, яке відкривається лише «по наведенню», там недосяжне. Екран вузький, тож кнопку може перекрити sticky-футер, банер cookies чи віртуальна клавіатура. А дрібна ціль, у яку миша влучає ідеально, пальцем натискається з третьої спроби.

Why it matters. Beyond events, a phone has no hover: a menu that opens only "on hover" is unreachable there. The screen is narrow, so a sticky footer, a cookie banner or the virtual keyboard can cover the button. And a small target that a mouse hits perfectly takes a finger three attempts.

Приклад. У Playwright емулюй мобільний контекст: isMobile: true, hasTouch: true, мобільний viewport — або готовий дескриптор з devices у проєкті конфігу. Тоді locator.tap() відправляє touch-події, а не мишачі. Без hasTouch метод tap() кидає помилку, і це корисне нагадування.

Example. In Playwright emulate a mobile context: isMobile: true, hasTouch: true, a mobile viewport — or a ready descriptor from devices in a config project. Then locator.tap() dispatches touch events instead of mouse ones. Without hasTouch the tap() method throws, which is a useful reminder.

Підводні камені. Емуляція — не реальний пристрій: WebKit у Playwright не є Safari на iOS, і віртуальна клавіатура, жести та системні панелі там не відтворюються. Для критичних флоу (оплата, логін) потрібен хоча б короткий прохід на справжньому телефоні або у фермі пристроїв. Розмір цілі перевіряй за WCAG 2.2: мінімум 24×24 CSS-пікселі, а рекомендації Apple і Google — близько 44–48.

Pitfalls. Emulation is not a real device: WebKit in Playwright is not Safari on iOS, and the virtual keyboard, gestures and system bars are not reproduced. Critical flows (payment, login) deserve at least a short pass on a real phone or a device farm. Check target size against WCAG 2.2: a minimum of 24×24 CSS pixels, while Apple and Google guidance sits around 44–48.

Уточнююче питання. «Як знайти hover-only меню?» Пошукай у Styles правила з :hover, що вмикають display підменю, і перевір, чи є для тачу альтернатива — клік або кнопка-перемикач.

Follow-up question. "How do you spot a hover-only menu?" Look in Styles for :hover rules that switch the submenu's display on, and check whether touch users get an alternative — a click or a toggle button.

Меню, недосяжне на тачі
A menu unreachable on touch
/* opens only on hover: no way in on a phone */
.menu .submenu        { display: none; }
.menu:hover .submenu  { display: block; }
Тап замість кліку в мобільному контексті
A tap instead of a click in a mobile context
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });

test('меню відкривається тапом', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Меню' }).tap(); // touch-події, не миша
  await expect(page.getByRole('navigation')).toBeVisible();
});
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });

test('menu opens with a tap', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Menu' }).tap(); // touch events, not the mouse
  await expect(page.getByRole('navigation')).toBeVisible();
});
🔧 Розбір синтаксису: .menu:hover .submenu — підменю всередині меню, на яке зараз наведено курсор; на тачі стану hover немає. У тесті hasTouch: true вмикає підтримку тачу в контексті, isMobile: true — мобільну поведінку вьюпорта, а getByRole('button', { name }) шукає кнопку за роллю і доступним ім'ям. .tap() імітує дотик пальцем.
🔧 Syntax breakdown: .menu:hover .submenu is the submenu inside a menu that the pointer is hovering; touch has no hover state. In the test hasTouch: true enables touch support in the context, isMobile: true turns on mobile viewport behaviour, and getByRole('button', { name }) finds the button by role and accessible name. .tap() simulates a finger touch.
💡 Простими словами: click() перевіряє мишу. Щоб перевірити палець, потрібні тач-контекст, tap() і хоча б інколи справжній телефон.
💡 In plain words: click() tests a mouse. To test a finger you need a touch context, tap() and, at least now and then, a real phone.
🗣 Як читати вголос Зелений клік доводить лише те, що кнопка реагує на мишу. На телефоні ланцюжок подій інший: спершу тач і поінтер, а клік браузер синтезує потім, і якщо обробник на тачі або хтось гасить подію, то на телефоні нічого не станеться. До того ж там немає ховера, тож меню по наведенню просто недоступне, а кнопку може закрити клавіатура чи липкий футер. Тому мобільні сценарії я ганяю в контексті з тачем і тапом замість кліку, перевіряю розмір цілей, а платіж і логін ще окремо проходжу руками на реальному пристрої, бо емуляція всього не покаже.
🗣 How to say it out loud A green click only proves the button reacts to a mouse. On a phone the event chain is different: touch and pointer events come first, and the browser synthesises the click afterwards, so if the handler sits on touch or someone cancels the event, nothing happens on the phone. There is also no hover, so a hover menu is simply out of reach, and the keyboard or a sticky footer can cover the button. That is why I run mobile scenarios in a touch context with tap instead of click, I check target sizes, and I still walk payment and login by hand on a real device.
17 Як CSS ламає тестованість: перекриття, pointer-events, анімації й overflow? How does CSS break testability: overlap, pointer-events, animations and overflow?
CSSCSS Middle ▶

Що це. Перед кліком Playwright перевіряє actionability: елемент видимий, стабільний (не рухається два кадри поспіль), увімкнений і отримує події — тобто в точці кліку зверху саме він. CSS легко ламає останні два пункти: position: fixed банер cookies, sticky-хедер з великим z-index, прозорий оверлей чи анімація появи.

What it is. Before a click Playwright checks actionability: the element is visible, stable (not moving across two frames), enabled and receives events — that is, it is the topmost element at the click point. CSS easily breaks the last two: a position: fixed cookie banner, a sticky header with a big z-index, a transparent overlay or an entrance animation.

Навіщо. Для тесту це виглядає як флейк: клік «інколи» падає з таймаутом, а в лозі Playwright пише, що інший елемент intercepts pointer events, і повторює спробу. Для користувача це справжній баг — він теж не може натиснути кнопку під банером. Тому такий лог — привід для репорту, а не для force: true.

Why it matters. In a test it looks like flakiness: the click "sometimes" times out, and the Playwright log says another element intercepts pointer events and retries. For the user it is a real bug — they cannot press the button under the banner either. So that log line is a reason to file a report, not to reach for force: true.

Приклад. На мобільній ширині кнопка «Оформити» ховається під липким футером. Замість force: true, який вимикає перевірки actionability, тест закриває банер так, як це зробив би користувач. Для скріншотних тестів анімації вимикаються: toHaveScreenshot за замовчуванням зупиняє CSS-анімації, а emulateMedia({ reducedMotion: 'reduce' }) вмикає режим зменшеного руху, якщо сайт його підтримує.

Example. At mobile width the "Checkout" button hides under a sticky footer. Instead of force: true, which turns the actionability checks off, the test dismisses the banner the way a user would. For screenshot tests animations are switched off: toHaveScreenshot stops CSS animations by default, and emulateMedia({ reducedMotion: 'reduce' }) enables reduced motion if the site supports it.

Підводні камені. pointer-events: none на елементі пропускає клік до того, що під ним, — звідси «клік потрапив не туди». overflow: hidden обрізає вміст, але для Playwright обрізаний елемент з непорожнім розміром лишається видимим, тож варто перевіряти скрол і реальну доступність. Ще одне джерело флейків — transition на розмірі, через який елемент довго «нестабільний».

Pitfalls. pointer-events: none on an element lets the click fall through to whatever is below — hence "the click landed somewhere else". overflow: hidden clips content, yet to Playwright a clipped element with a non-empty size is still visible, so check scrolling and real reachability. Another flake source is a transition on size, which keeps the element "unstable" for a long time.

Уточнююче питання. «Коли force: true допустимий?» Рідко: наприклад, для кастомного чекбокса, де справжній input навмисно прихований під стилізованою міткою, — і краще клікати по мітці.

Follow-up question. "When is force: true acceptable?" Rarely: for instance a custom checkbox where the real input is deliberately hidden under a styled label — and clicking the label is still better.

Звідки береться перекриття
Where the overlap comes from
.cookie-banner { position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; }
.sticky-footer { position: sticky; bottom: 0; z-index: 10; }
.loader-veil   { position: fixed; inset: 0; opacity: 0; } /* invisible, still on top */
Тест, що поводиться як користувач
A test that behaves like a user
// Лог Playwright:
//   <div class="cookie-banner">…</div> intercepts pointer events
//   retrying click action
await page.getByRole('button', { name: 'Прийняти все' }).click();
await page.getByRole('button', { name: 'Оформити' }).click();

// стабільний скріншот: без анімацій і з reduced motion
await page.emulateMedia({ reducedMotion: 'reduce' });
await expect(page).toHaveScreenshot({ animations: 'disabled' });
// Playwright log:
//   <div class="cookie-banner">…</div> intercepts pointer events
//   retrying click action
await page.getByRole('button', { name: 'Accept all' }).click();
await page.getByRole('button', { name: 'Checkout' }).click();

// a stable screenshot: no animations, reduced motion on
await page.emulateMedia({ reducedMotion: 'reduce' });
await expect(page).toHaveScreenshot({ animations: 'disabled' });
🔧 Розбір синтаксису: position: fixed прив'язує елемент до вікна, inset: 0 — скорочення для top/right/bottom/left: 0, тобто на весь екран. z-index визначає, хто зверху. У JS page.emulateMedia({ reducedMotion: 'reduce' }) імітує системне налаштування «менше руху», а опція animations: 'disabled' у toHaveScreenshot зупиняє анімації перед знімком.
🔧 Syntax breakdown: position: fixed pins an element to the window, inset: 0 is shorthand for top/right/bottom/left: 0, meaning full screen. z-index decides who is on top. In the JS page.emulateMedia({ reducedMotion: 'reduce' }) imitates the "reduce motion" system setting, and the animations: 'disabled' option of toHaveScreenshot stops animations before the capture.
💡 Простими словами: Якщо Playwright каже, що клік перехоплює інший елемент, — користувач теж не натисне. Лагодь сценарій, а не вимикай перевірки.
💡 In plain words: If Playwright says another element intercepts the click, the user cannot press it either. Fix the scenario instead of switching the checks off.
🗣 Як читати вголос Перед кожним кліком Playwright перевіряє, що елемент видимий, не рухається, увімкнений і саме він зверху в точці кліку. CSS найчастіше ламає останнє: банер кукі на фіксованій позиції, липкий хедер чи прозора вуаль лоадера. У лозі тоді видно, що інший елемент перехоплює події, і я сприймаю це як сигнал про реальний баг, бо людина теж не натисне кнопку. Форс тру я майже не використовую, він просто ховає проблему. Натомість закриваю банер як користувач, а для скріншотів вимикаю анімації і вмикаю зменшений рух, щоб знімки не мерехтіли.
🗣 How to say it out loud Before every click Playwright checks that the element is visible, not moving, enabled and actually on top at the click point. CSS usually breaks that last one: a fixed cookie banner, a sticky header or a transparent loader veil. The log then says another element intercepts the events, and I treat that as a signal of a real bug, because a person cannot press the button either. I almost never use force true, since it just hides the problem. Instead I dismiss the banner like a user would, and for screenshots I switch animations off and turn on reduced motion so the captures do not flicker.
18 Як побудувати матрицю перевірок адаптивності (ширини, DPR, орієнтація, браузери), щоб не вибухнула кількість тестів? How do you build a responsive test matrix (widths, DPR, orientation, browsers) without the number of tests exploding?
CSSCSS Senior ▶

Що це. Повний декартів добуток «10 ширин × 3 DPR × 2 орієнтації × 4 браузери» дає 240 прогонів кожного тесту, і більшість з них нічого нового не перевіряють. Матриця — це свідомий вибір, який ризик покриває кожна комбінація. Ширини беруть не з маркетингових списків пристроїв, а з breakpoints у CSS: по одній точці всередині кожного діапазону плюс межі для найважливіших компонентів.

What it is. The full Cartesian product of "10 widths × 3 DPR × 2 orientations × 4 browsers" gives 240 runs of every test, and most of them check nothing new. A matrix is a deliberate choice of which risk each combination covers. Widths come not from marketing device lists but from the breakpoints in the CSS: one point inside each range, plus the edges for the most important components.

Навіщо розділяти. Різні виміри ловлять різні баги. Ширина ламає розкладку, DPR — розмиті картинки і тонкі рамки, орієнтація — висоту модалок і клавіатуру, браузерний рушій — flex/grid-особливості й поведінку форм, тач — hover і розміри цілей. Тому функціональні сценарії йдуть на одному десктопному проєкті, а на мобільних проєктах — лише тести з тегом розкладки чи тачу.

Why split it. Each dimension catches different bugs. Width breaks layout, DPR produces blurry images and hairline borders, orientation breaks modal height and keyboard handling, the browser engine brings flex/grid quirks and form behaviour, and touch exposes hover and target sizes. So functional scenarios run on one desktop project, while mobile projects run only tests tagged for layout or touch.

Приклад. Конфіг Playwright з проєктами: повний регрес — на Desktop Chrome; планшет і мобільний WebKit — тільки тести @layout і @touch; мобільний Chromium — smoke. Аналітика підказує пріоритети: якщо 60% трафіку — iOS Safari, то WebKit-проєкт важливіший за Firefox на планшеті. Раз на спринт — короткий ручний прохід на реальних пристроях, бо емуляція не відтворює клавіатуру, safe area і системні жести.

Example. A Playwright config with projects: the full regression runs on Desktop Chrome; tablet and mobile WebKit run only @layout and @touch tests; mobile Chromium runs smoke. Analytics sets priorities: if 60% of traffic is iOS Safari, the WebKit project matters more than Firefox on a tablet. Once a sprint there is a short manual pass on real devices, because emulation does not reproduce the keyboard, the safe area or system gestures.

Підводні камені. Візуальні порівняння залежать від ОС, шрифтів і DPR, тож еталони знімають у тому самому Docker-образі, що й CI, інакше кожен прогін червоний. Емуляція WebKit — не Safari на iPhone. А теги без дисципліни розповзаються: без ревью матриця знову стає декартовою.

Pitfalls. Visual comparisons depend on the OS, fonts and DPR, so baselines are captured in the same Docker image as CI, otherwise every run is red. WebKit emulation is not Safari on an iPhone. And tags spread without discipline: without review the matrix quietly turns Cartesian again.

Уточнююче питання. «Як довести, що матриця достатня?» Прив'язати кожну комбінацію до конкретного ризику чи інциденту з прода і переглядати її, коли змінюються breakpoints або структура трафіку.

Follow-up question. "How do you show the matrix is enough?" Tie each combination to a concrete risk or production incident, and revisit it whenever the breakpoints or the traffic mix change.

Проєкти замість декартового добутку
Projects instead of a Cartesian product
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    // full regression once, on the main desktop target
    { name: 'desktop-chrome', use: { ...devices['Desktop Chrome'] } },
    // layout risk only: tablet width and mobile Safari engine
    { name: 'tablet', use: { ...devices['iPad Mini'] }, grep: /@layout/ },
    { name: 'mobile-webkit', use: { ...devices['iPhone 13'] }, grep: /@layout|@touch/ },
    // quick smoke on mobile Chromium
    { name: 'mobile-chrome', use: { ...devices['Pixel 7'] }, grep: /@smoke/ },
  ],
});
🔧 Розбір синтаксису: defineConfig дає типізований конфіг, projects — список конфігурацій запуску. ...devices['iPhone 13'] розгортає готовий дескриптор: viewport, deviceScaleFactor, userAgent, isMobile, hasTouch і тип браузера. grep: /@layout/ — регулярний вираз, за яким проєкт бере лише тести з таким тегом у назві чи в tag.
🔧 Syntax breakdown: defineConfig gives a typed config, projects is the list of run configurations. ...devices['iPhone 13'] spreads a ready descriptor: viewport, deviceScaleFactor, userAgent, isMobile, hasTouch and browser type. grep: /@layout/ is a regular expression so the project picks only tests carrying that tag in the title or in tag.
💡 Простими словами: Кожна клітинка матриці має відповідати на питання «який ризик вона ловить». Якщо відповіді немає — клітинка зайва.
💡 In plain words: Every cell of the matrix must answer "which risk does it catch". If there is no answer, the cell is waste.
🗣 Як читати вголос Я не будую матрицю як усі можливі поєднання, бо це сотні прогонів, які дублюють один одного. Спершу розкладаю виміри за ризиками: ширина ламає розкладку, щільність пікселів — картинки, орієнтація — модалки, рушій — флекс і форми, тач — ховер і розмір кнопок. Ширини беру з брейкпоінтів у CSS, а пріоритет браузерів з аналітики трафіку. Далі в Playwright роблю кілька проєктів: повний регрес на десктопі, а на мобільних лише тести з тегами розкладки й тачу. Скріншоти знімаю в тому ж докер-образі, що й CI, і раз на спринт проходжу ключові флоу на справжніх телефонах.
🗣 How to say it out loud I never build the matrix as every possible combination, because that is hundreds of runs duplicating each other. First I map the dimensions to risks: width breaks layout, pixel density breaks images, orientation breaks modals, the engine brings flex and form quirks, touch exposes hover and button sizes. Widths come from the CSS breakpoints and browser priority from traffic analytics. Then in Playwright I set up a few projects: full regression on desktop, and on mobile only the tests tagged for layout and touch. Screenshots are captured in the same Docker image as CI, and once a sprint I walk the key flows on real phones.
▶ 3 6
19 Що видно в «Переглянути код сторінки» на CSR-сторінці і чому там майже порожньо? What do you see in View Source on a CSR page, and why is it almost empty?
РендерингRendering Junior ▶

Що це. CSR (client-side rendering) означає, що сервер віддає лише оболонку: порожній контейнер на кшталт <div id="root"></div> і теги <script> з бандлами. Весь каталог, кнопки й тексти домальовує JavaScript уже в браузері, після того як завантажить код і сходить по дані в API. Тому «Переглянути код сторінки» показує майже нічого, а Elements — повноцінну сторінку.

What it is. CSR (client-side rendering) means the server sends only a shell: an empty container such as <div id="root"></div> and <script> tags with the bundles. The whole catalogue, the buttons and the text are drawn by JavaScript in the browser, once it has downloaded the code and fetched data from the API. That is why View Source shows next to nothing while Elements shows a complete page.

Навіщо це знати. Для тестера це пояснює кілька речей одразу. Асерт по тексту має чекати на рендер у браузері, а не на відповідь сервера: HTML прийшов за 100 мс, а список товарів з'явиться, коли відпрацює запит до /api/products. Інструменти без браузера (curl, простий HTTP-клієнт, частина SEO-ботів) бачать ту саму порожню оболонку.

Why it matters. For a tester it explains several things at once. A text assertion has to wait for the render in the browser, not for the server response: the HTML arrived in 100 ms, but the product list appears only after the request to /api/products has finished. Tools with no browser (curl, a plain HTTP client, some SEO bots) see the same empty shell.

Приклад. Баг «на сторінці категорії немає товарів» спершу розклади на шари: якщо в Network запит до API повернув 200 з порожнім масивом — проблема на бекенді; якщо дані прийшли, а DOM порожній — ламається рендер на клієнті, і шукати треба помилку в Console.

Example. For a bug like "the category page has no products", split it into layers first: if the API request in Network returned 200 with an empty array, the problem is in the backend; if the data came back but the DOM is empty, client rendering is failing and the Console is where to look for the error.

Підводні камені. Фраза «у коді сторінки такого тексту немає» для CSR нічого не доводить — його там і не повинно бути. І навпаки: якщо без JavaScript (DevTools → Command Menu → Disable JavaScript) сторінка біла, для CSR це норма, а не дефект.

Pitfalls. Saying "that text is not in the page source" proves nothing on CSR — it was never supposed to be there. The reverse also holds: if the page is blank with JavaScript switched off (DevTools → Command Menu → Disable JavaScript), that is normal for CSR, not a defect.

Уточнююче питання. «Як швидко зрозуміти, чи сайт CSR?» Відкрий Ctrl+U і пошукай характерний текст зі сторінки: якщо його немає, а є лише кореневий контейнер і скрипти, рендер клієнтський.

Follow-up question. "How do you tell quickly that a site is CSR?" Press Ctrl+U and search for some visible text from the page: if it is missing and there is only a root container plus scripts, rendering happens on the client.

Що бачить curl на CSR-сторінці
What curl sees on a CSR page
curl -s https://shop.test/catalog
<!doctype html>
<html><head><script type="module" src="/assets/index-4f2a1c.js"></script></head>
<body><div id="root"></div></body></html>

# Текст картки товару у відповіді сервера відсутній:
curl -s https://shop.test/catalog | grep -c 'Кросівки'   # 0
curl -s https://shop.test/catalog
<!doctype html>
<html><head><script type="module" src="/assets/index-4f2a1c.js"></script></head>
<body><div id="root"></div></body></html>

# The product card text is not in the server response:
curl -s https://shop.test/catalog | grep -c 'Sneakers'   # 0
🔧 Розбір синтаксису: curl -s — запит без індикатора прогресу, виводить лише тіло відповіді, тобто рівно те, що показав би Ctrl+U. | передає вивід у наступну команду, а grep -c рахує рядки зі збігом. type="module" — сучасний спосіб підключити ES-модуль бандла; хеш 4f2a1c в імені файлу змінюється з кожним білдом.
🔧 Syntax breakdown: curl -s sends a request with no progress meter and prints just the response body — exactly what Ctrl+U would show. | pipes the output into the next command, and grep -c counts the matching lines. type="module" is the modern way to load the bundle as an ES module; the 4f2a1c hash in the file name changes with every build.
💡 Простими словами: На CSR сервер присилає порожню коробку, а меблі в неї заносить JavaScript у твоєму браузері.
💡 In plain words: On CSR the server sends an empty box, and JavaScript carries the furniture in inside your browser.
🗣 Як читати вголос Якщо сторінка рендериться на клієнті, то в коді сторінки я побачу практично лише кореневий div і підключені скрипти. Увесь контент малює JavaScript після того, як підтягне дані з API. Для мене це означає дві речі. Перша: мої перевірки тексту мають чекати, поки браузер відмалює дані, а не просто поки прийшов HTML. Друга: коли на сторінці пусто, я дивлюсь, чи повернув API дані, і вже потім, чи є помилки в консолі — так я одразу розумію, чий це баг, фронтенду чи бекенду. І я ніколи не аргументую «цього немає в коді сторінки», бо для CSR це нормально.
🗣 How to say it out loud When a page is rendered on the client, the page source gives me little more than a root div and the script tags. JavaScript draws all the content after it pulls the data from the API. That tells me two things. First, my text checks must wait until the browser has drawn the data, not merely until the HTML arrived. Second, when the page is empty I check whether the API returned data and only then look for console errors, so I know straight away whose bug it is, frontend or backend. And I never argue "it is not in the page source", because on CSR that is expected.
20 Чим SSG відрізняється від SSR і CSR і що це змінює для тестування? How does SSG differ from SSR and CSR, and what does that change for testing?
РендерингRendering Junior ▶

Що це. Три підходи відрізняються тим, коли і де з'являється HTML. SSG (static site generation) рендерить сторінки один раз на етапі білду і кладе готові файли на CDN. SSR (server-side rendering) збирає HTML на сервері на кожен запит. CSR віддає оболонку, а розмітку будує JavaScript у браузері. SSG і SSR приносять готовий текст у відповіді сервера, CSR — ні.

What it is. The three approaches differ in when and where the HTML is produced. SSG (static site generation) renders the pages once at build time and puts ready files on a CDN. SSR (server-side rendering) builds the HTML on the server for every request. CSR sends a shell, and JavaScript builds the markup in the browser. SSG and SSR carry finished text in the server response, CSR does not.

Навіщо тестеру. Від моделі рендерингу залежать типові баги. У SSG дані застигають до наступного білду: змінили ціну в адмінці, а на сайті стара, доки не пройде деплой або перегенерація. У SSR дані свіжі, але сторінка напряму залежить від живого бекенду: повільний API — повільний TTFB, впав сервіс — 500 замість сторінки. У CSR найчастіше ламаються стани завантаження й помилок у самому браузері.

Why it matters for testing. The typical bugs follow from the rendering model. With SSG the data is frozen until the next build: a price changed in the admin panel stays old on the site until a deploy or a regeneration happens. With SSR the data is fresh, but the page depends directly on a live backend: a slow API means a slow TTFB, a dead service means a 500 instead of a page. With CSR it is usually the loading and error states in the browser that break.

Приклад. Контент-менеджер скаржиться, що новий банер з'явився лише через годину. Якщо блог зібраний як SSG з перегенерацією раз на годину (у Next.js це ISR), то це очікувана поведінка, а не баг — і правильне питання до команди: яка вимога до свіжості даних.

Example. A content editor complains that a new banner showed up only an hour later. If the blog is built as SSG with hourly regeneration (in Next.js that is ISR), this is expected behaviour, not a bug — and the right question for the team is what the freshness requirement for that data is.

Підводні камені. Сучасні фреймворки змішують підходи на рівні окремих сторінок: головна може бути SSG, кошик — CSR, картка товару — SSR. Тож модель рендерингу варто з'ясовувати для конкретного маршруту, а не для всього сайту.

Pitfalls. Modern frameworks mix the approaches per page: the home page may be SSG, the cart CSR, the product page SSR. So find out the rendering model for a particular route, not for the whole site.

Уточнююче питання. «Як це перевірити без доступу до коду?» Порівняй текст у відповіді сервера через curl з тим, що видно на екрані, і подивись заголовки Age, Cache-Control, Last-Modified: статичний файл з CDN зазвичай видно одразу.

Follow-up question. "How do you check it without access to the code?" Compare the text in the server response from curl with what is on screen, and look at the Age, Cache-Control and Last-Modified headers: a static file served from a CDN usually gives itself away at once.

Як з терміналу вгадати модель рендерингу
Guessing the rendering model from a terminal
# 1. Чи є контент у відповіді сервера? (SSG/SSR — так, CSR — ні)
curl -s https://shop.test/blog/summer-sale | grep -o '<h1>[^<]*</h1>'

# 2. Чи це статичний файл з CDN? Дивимось заголовки
curl -sI https://shop.test/blog/summer-sale | grep -iE '^(age|cache-control|last-modified):'
age: 1843
cache-control: public, max-age=3600
last-modified: Mon, 28 Sep 2026 09:00:00 GMT
# 1. Is the content in the server response? (SSG/SSR yes, CSR no)
curl -s https://shop.test/blog/summer-sale | grep -o '<h1>[^<]*</h1>'

# 2. Is it a static file from a CDN? Check the headers
curl -sI https://shop.test/blog/summer-sale | grep -iE '^(age|cache-control|last-modified):'
age: 1843
cache-control: public, max-age=3600
last-modified: Mon, 28 Sep 2026 09:00:00 GMT
🔧 Розбір синтаксису: grep -o виводить лише сам збіг, а [^<]* означає «будь-які символи, крім <», тобто текст усередині заголовка. curl -I робить HEAD-запит і показує тільки заголовки. grep -iE вмикає пошук без урахування регістру і розширені регулярні вирази, де (a|b) — вибір одного з варіантів. age: 1843 означає, що кеш тримає цю копію вже 1843 секунди.
🔧 Syntax breakdown: grep -o prints only the match itself, and [^<]* means "any characters except <", i.e. the text inside the heading. curl -I sends a HEAD request and shows only the headers. grep -iE turns on case-insensitive matching and extended regular expressions, where (a|b) picks one of the alternatives. age: 1843 means a cache has held this copy for 1843 seconds.
💡 Простими словами: SSG — надрукували газету заздалегідь, SSR — друкують на твоє замовлення, CSR — дали чистий аркуш і ручку браузеру.
💡 In plain words: SSG prints the newspaper in advance, SSR prints it when you order, CSR hands the browser a blank sheet and a pen.
🗣 Як читати вголос Для мене різниця в тому, коли народжується HTML. У статичній генерації — один раз під час білду, далі це просто файли на CDN. У серверному рендерингу — на кожен запит, тобто завжди свіжі дані. У клієнтському — вже в браузері. І з цього одразу видно, які баги шукати: у статиці це застарілий контент до наступного деплою, у серверному рендері — залежність від швидкості й доступності бекенду, у клієнтському — стани завантаження і помилок. На проєкті я уточнюю модель для кожного маршруту, бо фреймворки часто їх змішують, а без доступу до коду допомагають curl і заголовки кешу.
🗣 How to say it out loud For me the difference is when the HTML is born. With static generation it happens once during the build, and after that they are just files on a CDN. With server rendering it happens on every request, so the data is always fresh. With client rendering it happens in the browser. That tells me right away which bugs to hunt: with static pages it is stale content until the next deploy, with server rendering it is dependence on backend speed and uptime, with client rendering it is the loading and error states. On a project I confirm the model per route, because frameworks mix them, and without code access curl and the cache headers do the job.
21 Як завантажується SSR-сторінка з погляду тестера — по кроках? How does an SSR page load from a tester's point of view, step by step?
РендерингRendering Middle ▶

Що це. Послідовність така: сервер отримує запит і віддає готовий HTML уже з даними; браузер малює цю розмітку — текст видно, і скріншот вийде «правильним»; далі завантажується і виконується JS-бандл сторінки; нарешті гідратація навішує обробники подій, і лише тепер кнопки реагують на кліки.

What it is. The sequence goes like this: the server receives the request and returns finished HTML that already contains the data; the browser paints that markup — the text is visible and a screenshot looks "right"; then the page's JS bundle downloads and executes; finally hydration attaches the event handlers, and only now do the buttons react to clicks.

Навіщо. Між другим і четвертим кроком сторінка виглядає готовою, але не приймає введення. Саме в цій щілині живе більшість нестабільних тестів на SSR: тест бачить кнопку, клікає, а обробника ще немає, і клік просто губиться. Людина на швидкому ноутбуці щілини не помітить, а на слабкому телефоні вона триває секунди.

Why it matters. Between the second and the fourth step the page looks ready but does not accept input. Most flaky SSR tests live in that gap: the test sees the button, clicks it, the handler is not there yet, and the click is simply lost. A person on a fast laptop never notices the gap, on a weak phone it lasts for seconds.

Приклад. У Network це читається по waterfall: спершу документ з довгим Waiting (сервер рендерить), потім CSS і шрифти, потім JS-чанки. У вкладці Performance видно First Contentful Paint задовго до того, як завершиться довга задача з виконанням бандла. Якщо увімкнути throttling «Slow 4G» і CPU 4x slowdown, щілина стає настільки широкою, що баг легко відтворити руками.

Example. In Network you read it from the waterfall: first the document with a long Waiting segment (the server is rendering), then CSS and fonts, then the JS chunks. The Performance panel shows First Contentful Paint well before the long task that executes the bundle has finished. Turn on "Slow 4G" throttling and a 4x CPU slowdown, and the gap becomes wide enough to reproduce the bug by hand.

Підводні камені. Події DOMContentLoaded і load не означають завершеної гідратації: фреймворк може гідратувати пізніше, частинами або ліниво. Тому waitUntil: 'load' у тесті нічого не гарантує щодо інтерактивності.

Pitfalls. The DOMContentLoaded and load events do not mean hydration is complete: a framework may hydrate later, in chunks or lazily. So waitUntil: 'load' in a test guarantees nothing about interactivity.

Уточнююче питання. «Чим TTFB SSR-сторінки відрізняється від статичної?» У SSR сервер витрачає час на збирання даних і рендер, тож TTFB помітно довший і прямо залежить від швидкості бекенду.

Follow-up question. "How is the TTFB of an SSR page different from a static one?" With SSR the server spends time gathering data and rendering, so TTFB is noticeably longer and depends directly on backend speed.

Кроки завантаження SSR у тесті Playwright
SSR load steps in a Playwright test
test('кнопка працює лише після гідратації', async ({ page }) => {
  // Крок 1: сервер віддав документ — чекаємо лише на відповідь
  const response = await page.goto('/product/42', { waitUntil: 'commit' });
  expect(response.status()).toBe(200);

  // Крок 2: розмітка намальована — текст уже є
  await expect(page.getByRole('heading', { name: 'Кросівки Run' })).toBeVisible();

  // Кроки 3–4: бандл виконався, гідратація завершилась —
  // перевіряємо реакцію на дію, а не лише наявність кнопки
  await page.getByRole('button', { name: 'Додати в кошик' }).click();
  await expect(page.getByTestId('cart-count')).toHaveText('1');
});
test('button works only after hydration', async ({ page }) => {
  // Step 1: the server returned the document — wait for the response only
  const response = await page.goto('/product/42', { waitUntil: 'commit' });
  expect(response.status()).toBe(200);

  // Step 2: the markup is painted — the text is already there
  await expect(page.getByRole('heading', { name: 'Run Sneakers' })).toBeVisible();

  // Steps 3–4: the bundle ran and hydration finished —
  // check the reaction to an action, not just that the button exists
  await page.getByRole('button', { name: 'Add to cart' }).click();
  await expect(page.getByTestId('cart-count')).toHaveText('1');
});
🔧 Розбір синтаксису: waitUntil: 'commit' повертає керування, щойно браузер отримав відповідь і почав навігацію — раніше за domcontentloaded і load. response.status() читає HTTP-статус документа. expect(locator).toBeVisible() і toHaveText() — web-first перевірки, які самі повторюються до таймауту. Остання пара рядків перевіряє наслідок кліку: лічильник кошика змінився.
🔧 Syntax breakdown: waitUntil: 'commit' hands control back as soon as the browser has received the response and started navigating — earlier than domcontentloaded and load. response.status() reads the document's HTTP status. expect(locator).toBeVisible() and toHaveText() are web-first assertions that retry on their own until the timeout. The last pair of lines checks the consequence of the click: the cart counter changed.
💡 Простими словами: SSR-сторінка спершу стає схожою на готову, і лише потім — справді готовою. Тестуй другий момент, а не перший.
💡 In plain words: An SSR page first looks ready and only later is ready. Test the second moment, not the first.
🗣 Як читати вголос Я розкладаю це на чотири кроки. Спочатку сервер віддає HTML уже з даними. Потім браузер його малює, і сторінка виглядає цілком готовою, навіть на скріншоті. Далі вантажиться і виконується JavaScript. І тільки після гідратації на кнопках з'являються обробники. Найцікавіше для мене — проміжок між малюванням і гідратацією: там кліки губляться, і звідти береться більшість флаків. Тому в тестах я перевіряю не те, що кнопка видима, а те, що дія дала результат, а руками відтворюю такі баги з повільною мережею і сповільненим процесором у DevTools.
🗣 How to say it out loud I break it into four steps. First the server returns HTML that already has the data. Then the browser paints it, and the page looks completely ready, even in a screenshot. Next the JavaScript loads and runs. Only after hydration do the buttons get their handlers. The part I care about most is the gap between painting and hydration: clicks get lost there, and that is where most of the flakiness comes from. So in tests I do not check that the button is visible, I check that the action produced a result, and by hand I reproduce these bugs with a slow network and a throttled CPU in DevTools.
22 Що таке гідратація і який типовий симптом, коли вона не завершилась? What is hydration, and what is the typical symptom when it has not finished?
РендерингRendering Middle ▶

Що це. Гідратація (hydration) — процес, у якому JavaScript фреймворку «оживляє» HTML, що вже намальований сервером: проходить по готових вузлах, зіставляє їх зі своїм деревом компонентів і навішує обробники подій. Нової розмітки при цьому майже не створюється — вона вже є, бракує лише поведінки.

What it is. Hydration is the process in which the framework's JavaScript "brings to life" HTML the server has already painted: it walks the existing nodes, matches them against its component tree and attaches the event handlers. Almost no new markup is created — it is already there, only the behaviour is missing.

Симптом. Текст і кнопки вже на екрані, але якийсь час кліки нічого не роблять: форма не відправляється, меню не відкривається, лічильник не змінюється. Жодної помилки може й не бути, і скріншот такого не покаже, бо візуально все гаразд. Другий класичний симптом — hydration mismatch: HTML із сервера не збігся з тим, що відрендерив клієнт, і в Console з'являється попередження, а частина сторінки перемальовується або «блимає».

Symptom. Text and buttons are already on screen, yet for a while clicks do nothing: the form will not submit, the menu will not open, the counter will not change. There may be no error at all, and a screenshot will not show it, because visually everything is fine. The second classic symptom is a hydration mismatch: the HTML from the server did not match what the client rendered, a warning appears in the Console, and part of the page re-renders or "flickers".

Приклад. Сервер рендерить дату в UTC, а браузер — у часовому поясі користувача, тому текст відрізняється і React повідомляє про mismatch. Інший частий випадок — Math.random() або дані з localStorage під час рендеру: на сервері їх немає, у браузері вони є.

Example. The server renders a date in UTC while the browser renders it in the user's time zone, so the text differs and React reports a mismatch. Another frequent case is Math.random() or data from localStorage during render: the server does not have them, the browser does.

Підводні камені. Auto-waiting у Playwright перевіряє, що елемент видимий, стабільний і доступний, але не знає, чи висить на ньому обробник. Тому тест клікає в щілину до гідратації й падає нестабільно. waitForTimeout лише ховає проблему; краще перевіряти наслідок дії з повторами або чекати на явний сигнал від застосунку.

Pitfalls. Playwright's auto-waiting checks that an element is visible, stable and enabled, but it has no idea whether a handler is attached. So the test clicks into the pre-hydration gap and fails intermittently. waitForTimeout only hides the problem; it is better to assert the consequence of the action with retries or to wait for an explicit signal from the app.

Уточнююче питання. «Як відтворити руками?» Performance → CPU 6x slowdown плюс Slow 4G у Network, і клікай одразу, щойно з'явився текст.

Follow-up question. "How do you reproduce it by hand?" Performance → 6x CPU slowdown plus Slow 4G in Network, and click the moment the text appears.

Стабільний клік на SSR-сторінці
A stable click on an SSR page
// Варіант 1: застосунок ставить маркер після гідратації
// (домовленість з розробниками, наприклад у root-компоненті)
await expect(page.locator('html[data-hydrated="true"]')).toBeAttached();
await page.getByRole('button', { name: 'Відкрити меню' }).click();

// Варіант 2: повторювати дію, доки не з'явиться результат
await expect(async () => {
  await page.getByRole('button', { name: 'Відкрити меню' }).click();
  await expect(page.getByRole('menu')).toBeVisible({ timeout: 1000 });
}).toPass({ timeout: 10000 });
// Option 1: the app sets a marker after hydration
// (agreed with the developers, e.g. in the root component)
await expect(page.locator('html[data-hydrated="true"]')).toBeAttached();
await page.getByRole('button', { name: 'Open menu' }).click();

// Option 2: repeat the action until the result shows up
await expect(async () => {
  await page.getByRole('button', { name: 'Open menu' }).click();
  await expect(page.getByRole('menu')).toBeVisible({ timeout: 1000 });
}).toPass({ timeout: 10000 });
🔧 Розбір синтаксису: html[data-hydrated="true"] — CSS-селектор за значенням атрибута; toBeAttached() чекає, поки такий вузол з'явиться в DOM. expect(async () => {...}).toPass() повторює весь блок, доки він не пройде без винятку або не вийде зовнішній таймаут; внутрішній timeout: 1000 робить кожну спробу короткою. Другий варіант годиться лише для ідемпотентних дій: кнопку «Оплатити» так повторювати не можна.
🔧 Syntax breakdown: html[data-hydrated="true"] is a CSS selector on an attribute value; toBeAttached() waits until such a node is in the DOM. expect(async () => {...}).toPass() retries the whole block until it passes without throwing or the outer timeout runs out; the inner timeout: 1000 keeps each attempt short. The second option only suits idempotent actions: you must not retry a "Pay" button like that.
💡 Простими словами: До гідратації сторінка — це фотографія інтерфейсу: усе видно, але нічого не натискається.
💡 In plain words: Before hydration the page is a photograph of the interface: you can see everything, but nothing can be pressed.
🗣 Як читати вголос Гідратація — це коли JavaScript бере вже намальований сервером HTML і навішує на нього обробники, тобто робить з картинки робочий інтерфейс. Типовий симптом, що вона не завершилась: я бачу текст і кнопки, а клік нічого не робить, причому ні помилки, ні різниці на скріншоті. Ще один сигнал — попередження про hydration mismatch у консолі, коли серверний і клієнтський рендер розійшлися, наприклад через дату в різних часових поясах. В автотестах я не лікую це паузами: або прошу розробників виставити маркер готовності, або перевіряю результат дії з повторами.
🗣 How to say it out loud Hydration is when JavaScript takes the HTML the server has already painted and attaches handlers to it, turning a picture into a working interface. The typical sign it has not finished: I can see text and buttons, but a click does nothing, with no error and no difference in a screenshot. Another signal is a hydration mismatch warning in the console, when the server and client renders disagree, say because of a date in two different time zones. In automation I do not cure this with sleeps: either I ask the developers to expose a readiness marker, or I assert the result of the action with retries.
23 Що таке critical rendering path і як Core Web Vitals (LCP, INP, CLS) проявляються в тестуванні? What is the critical rendering path, and how do Core Web Vitals (LCP, INP, CLS) show up in testing?
РендерингRendering Middle ▶

Що це. Critical rendering path — ланцюжок кроків, через який браузер проходить до першого малювання: отримати HTML, побудувати DOM, завантажити і розібрати CSS у CSSOM, виконати синхронні скрипти, скласти render tree, розрахувати layout і намалювати пікселі. CSS і синхронний <script> у <head> блокують рендер, тому кожен такий ресурс відсуває момент, коли користувач щось побачить.

What it is. The critical rendering path is the chain of steps a browser goes through before the first paint: get the HTML, build the DOM, download and parse the CSS into the CSSOM, run synchronous scripts, build the render tree, compute layout and paint the pixels. CSS and a synchronous <script> in the <head> block rendering, so every such resource pushes back the moment the user sees anything.

Core Web Vitals. Це три метрики Google про реальний досвід. LCP (Largest Contentful Paint) — коли намальовано найбільший елемент у видимій області; добре — до 2,5 с. INP (Interaction to Next Paint) — затримка від взаємодії до наступного кадру; добре — до 200 мс; у березні 2024 INP замінив FID. CLS (Cumulative Layout Shift) — сумарні неочікувані зсуви макета; добре — до 0,1. Пороги оцінюють за 75-м перцентилем реальних візитів.

Core Web Vitals. These are Google's three metrics of real user experience. LCP (Largest Contentful Paint) is when the largest element in the viewport has been painted; good means up to 2.5 s. INP (Interaction to Next Paint) is the delay from an interaction to the next frame; good means up to 200 ms; INP replaced FID in March 2024. CLS (Cumulative Layout Shift) sums up unexpected layout shifts; good means up to 0.1. The thresholds are judged at the 75th percentile of real visits.

Приклад. Кнопка «Оформити» зсувається вниз, бо над нею пізно довантажився банер без заданої висоти, і користувач клікає по рекламі — це CLS, і це функціональний баг, а не лише «повільно». Великий hero-банер з loading="lazy" на першому екрані затримує LCP.

Example. The "Checkout" button jumps down because a banner with no reserved height loaded late above it, and the user clicks the ad instead — that is CLS, and it is a functional bug, not just "slowness". A large hero banner with loading="lazy" on the first screen delays LCP.

Підводні камені. INP і CLS накопичуються протягом усього візиту, тож одне завантаження в Lighthouse їх не покаже повністю: Lighthouse не вимірює INP взагалі, замість нього орієнтуйся на Total Blocking Time.

Pitfalls. INP and CLS accumulate over the whole visit, so a single Lighthouse load will not show them fully: Lighthouse does not measure INP at all, and Total Blocking Time is the lab proxy to watch instead.

Уточнююче питання. «Де подивитись?» Performance → Live metrics показує LCP, CLS і INP для твоїх дій наживо.

Follow-up question. "Where do you look?" Performance → Live metrics shows LCP, CLS and INP for your own actions as they happen.

Сніпет для Console: LCP і зсуви макета
Console snippet: LCP and layout shifts
// Встав у Console до взаємодії зі сторінкою
new PerformanceObserver((list) => {
  const last = list.getEntries().at(-1);
  console.log('LCP', Math.round(last.startTime), 'ms', last.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });

let cls = 0;
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (!e.hadRecentInput) cls += e.value; // зсуви після вводу не рахуються
  }
  console.log('CLS', cls.toFixed(3));
}).observe({ type: 'layout-shift', buffered: true });
// Paste into the Console before interacting with the page
new PerformanceObserver((list) => {
  const last = list.getEntries().at(-1);
  console.log('LCP', Math.round(last.startTime), 'ms', last.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });

let cls = 0;
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (!e.hadRecentInput) cls += e.value; // shifts right after input do not count
  }
  console.log('CLS', cls.toFixed(3));
}).observe({ type: 'layout-shift', buffered: true });
🔧 Розбір синтаксису: PerformanceObserver — стандартний API браузера, який викликає колбек, коли з'являються записи обраного типу. buffered: true віддає й ті записи, що сталися до створення спостерігача. list.getEntries().at(-1) бере останнього кандидата в LCP, а last.element показує сам вузол — клікни по ньому в Console, і DevTools підсвітить його. hadRecentInput відсіює зсуви одразу після дій користувача, як і в самій метриці (спрощено: справжній CLS групує зсуви у вікна).
🔧 Syntax breakdown: PerformanceObserver is a standard browser API that calls the callback when entries of the chosen type appear. buffered: true also delivers the entries recorded before the observer was created. list.getEntries().at(-1) takes the latest LCP candidate, and last.element is the node itself — click it in the Console and DevTools highlights it. hadRecentInput filters out shifts right after user input, as the metric does (simplified: the real CLS groups shifts into windows).
💡 Простими словами: LCP — як швидко видно головне, INP — як швидко сторінка відповідає на дію, CLS — чи не стрибає все під пальцем.
💡 In plain words: LCP is how fast the main thing appears, INP is how fast the page answers an action, CLS is whether things jump under your finger.
🗣 Як читати вголос Критичний шлях рендерингу — це все, що браузер мусить зробити до першого кадру: розібрати HTML, завантажити CSS, виконати синхронні скрипти, порахувати layout і намалювати. Все, що блокує рендер у head, прямо відсуває момент, коли людина щось бачить. Core Web Vitals я пояснюю через досвід користувача: LCP — коли з'явився головний контент, норма до двох з половиною секунд; INP — наскільки швидко сторінка відповідає на клік, норма до двохсот мілісекунд, і він замінив FID; CLS — чи стрибає верстка, норма до однієї десятої. Зсув кнопки під пальцем я заводжу як функціональний баг.
🗣 How to say it out loud The critical rendering path is everything the browser must do before the first frame: parse the HTML, fetch the CSS, run synchronous scripts, compute layout and paint. Anything render-blocking in the head pushes back the moment a person sees something. I explain Core Web Vitals through the user's experience: LCP is when the main content appeared, good is under two and a half seconds; INP is how quickly the page answers a click, good is under two hundred milliseconds, and it replaced FID; CLS is whether the layout jumps, good is under a tenth. A button sliding away under someone's finger is something I file as a functional bug.
24 Як довести регресію продуктивності після релізу і не зробити хибних висновків з Lighthouse? How do you prove a performance regression after a release without drawing false conclusions from Lighthouse?
РендерингRendering Senior ▶

Ситуація. Після релізу продакт каже «сайт став повільнішим», а один прогін Lighthouse на твоєму ноутбуці показує 62 замість 85. Сам по собі цей факт майже нічого не доводить: результат одного лабораторного прогону коливається на 5–15 балів через навантаження машини, мережу, розширення браузера й сторонні скрипти.

Situation. After a release the product owner says "the site got slower", and one Lighthouse run on your laptop shows 62 instead of 85. On its own that proves almost nothing: a single lab run swings by 5–15 points because of machine load, the network, browser extensions and third-party scripts.

Lab проти field. Lab-дані (Lighthouse, Performance panel, WebPageTest) — контрольований експеримент: зручно шукати причину, але це не реальні користувачі. Field-дані (CrUX, власний RUM через бібліотеку web-vitals) показують 75-й перцентиль реальних візитів, але реагують на реліз із затримкою: CrUX агрегує за 28 днів. Висновок про регресію спирається на field, а пояснення причини — на lab.

Lab versus field. Lab data (Lighthouse, the Performance panel, WebPageTest) is a controlled experiment: good for finding a cause, but these are not real users. Field data (CrUX, your own RUM through the web-vitals library) shows the 75th percentile of real visits, but it reacts to a release with a delay: CrUX aggregates over 28 days. A claim of regression rests on field data, and the explanation of the cause rests on lab data.

Як довести. Порівнюй дві версії в однакових умовах: той самий URL, та сама емуляція пристрою і throttling, режим інкогніто без розширень, 5–9 прогонів на версію і медіана, а не найкращий результат. Далі — Performance trace для обох версій: нова довга задача, новий render-blocking скрипт, більший LCP-ресурс. Регресія доведена, коли є стабільна різниця медіан і конкретна причина в trace або в diff бандла.

How to prove it. Compare the two versions under identical conditions: the same URL, the same device emulation and throttling, an incognito profile with no extensions, 5–9 runs per version and the median, not the best result. Then take a Performance trace of both: a new long task, a new render-blocking script, a heavier LCP resource. The regression is proven when there is a stable gap between the medians and a concrete cause in the trace or in the bundle diff.

Трейдофи. Бюджети в CI (Lighthouse CI з кількома прогонами й assert на метрики) ловлять регресії до релізу, але шумні: жорсткий поріг дає флаки, тому бюджет ставлять із запасом і на метрики, а не на загальний бал.

Trade-offs. Budgets in CI (Lighthouse CI with several runs and assertions on metrics) catch regressions before release, but they are noisy: a tight threshold makes them flaky, so the budget gets some headroom and targets metrics rather than the overall score.

Уточнююче питання. «Чому бал упав, а метрики ні?» Ваги метрик у балі змінюються між версіями Lighthouse — порівнюй однакову версію.

Follow-up question. "Why did the score drop while the metrics did not?" Metric weights inside the score change between Lighthouse versions — compare runs of the same version.

lighthouserc.json: кілька прогонів і бюджет на метрики
lighthouserc.json: several runs and a metric budget
{
  "ci": {
    "collect": {
      "url": ["https://staging.shop.test/product/42"],
      "numberOfRuns": 5
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time": ["warn", { "maxNumericValue": 300 }]
      }
    }
  }
}
// run in the pipeline: npx @lhci/cli autorun
🔧 Розбір синтаксису: collect.numberOfRuns — скільки разів прогнати кожен URL; для перевірок береться репрезентативний (медіанний) прогін. assertions задають бюджет за ідентифікатором аудиту: "error" валить пайплайн, "warn" лише попереджає, maxNumericValue — межа в мілісекундах для часових метрик і безрозмірне число для CLS. npx @lhci/cli autorun сам виконує collect, assert і upload за цим конфігом.
🔧 Syntax breakdown: collect.numberOfRuns is how many times each URL is run; assertions use a representative (median) run. assertions set a budget per audit id: "error" fails the pipeline, "warn" only warns, maxNumericValue is a limit in milliseconds for time metrics and a unitless number for CLS. npx @lhci/cli autorun runs collect, assert and upload from this config by itself.
💡 Простими словами: Один прогін Lighthouse — це анекдот, медіана прогонів — спостереження, а field-дані — доказ того, що болить користувачам.
💡 In plain words: One Lighthouse run is an anecdote, a median of runs is an observation, and field data is proof that users are hurting.
🗣 Як читати вголос Я б не заводив баг на основі одного прогону Lighthouse, бо він легко гуляє на десяток балів. Спершу дивлюсь field-дані — CrUX або наш RUM, — щоб зрозуміти, чи реальним користувачам справді гірше, пам'ятаючи, що CrUX збирає дані за двадцять вісім днів. Потім відтворюю в лабораторії: однаковий URL, однакова емуляція і throttling, чистий профіль, п'ять-дев'ять прогонів на стару й нову версію, і порівнюю медіани. Причину шукаю в Performance trace: нова довга задача, блокуючий скрипт, важче LCP-зображення. А щоб не ловити таке після релізу, ставлю бюджети в CI на метрики з розумним запасом.
🗣 How to say it out loud I would not file a bug off one Lighthouse run, since it easily wanders by ten points. First I check field data — CrUX or our own RUM — to see whether real users are actually worse off, keeping in mind CrUX covers twenty-eight days. Then I reproduce in the lab: the same URL, the same emulation and throttling, a clean profile, five to nine runs on the old and the new version, and I compare medians. The cause I look for in a Performance trace: a new long task, a blocking script, a heavier LCP image. And to stop catching this after release, I put metric budgets with sensible headroom into CI.
▶ 4 7
25 Чим відрізняються cookies, localStorage, sessionStorage та IndexedDB і де їх дивитись? How do cookies, localStorage, sessionStorage and IndexedDB differ, and where do you inspect them?
СховищеStorage Junior ▶

Що це. Це чотири місця, де браузер тримає дані сайту. Cookies — маленькі пари «ключ=значення» (близько 4 КБ на штуку), які браузер сам додає до кожного запиту на відповідний домен. localStorage — синхронне сховище рядків на кілька мегабайтів, живе, доки його не очистять. sessionStorage має той самий API, але прив'язаний до однієї вкладки і зникає разом із нею. IndexedDB — асинхронна база об'єктів з індексами і транзакціями для великих обсягів, наприклад офлайн-кошика чи кешу повідомлень.

What it is. These are four places where the browser keeps a site's data. Cookies are small key=value pairs (about 4 KB each) that the browser attaches by itself to every request to the matching domain. localStorage is a synchronous string store of a few megabytes that lives until someone clears it. sessionStorage has the same API but belongs to a single tab and disappears with it. IndexedDB is an asynchronous object database with indexes and transactions for larger volumes, such as an offline cart or a message cache.

Навіщо тестеру. Від вибору сховища залежить поведінка, яку ти перевіряєш: чи переживе стан закриття вкладки, чи побачить його друга вкладка, чи піде він на сервер без участі JS. Усі чотири ізольовані за origin (схема + хост + порт), тому http:// і https:// версії сайту — це різні сховища.

Why it matters. The storage choice decides the behaviour you are testing: whether state survives closing the tab, whether a second tab sees it, whether it reaches the server without any JS involved. All four are isolated per origin (scheme + host + port), so the http:// and https:// versions of a site are separate stores.

Приклад. Відкрий DevTools → Application → Storage. Там окремі гілки Cookies, Local storage, Session storage, IndexedDB, а кнопка Clear site data чистить усе разом. Для швидкої перевірки з консолі можна вивести вміст кожного сховища, як у прикладі нижче.

Example. Open DevTools → Application → Storage. You get separate branches for Cookies, Local storage, Session storage and IndexedDB, and the Clear site data button wipes them all at once. For a quick check from the console you can dump each store, as in the snippet below.

Підводні камені. localStorage і sessionStorage зберігають лише рядки, тож об'єкт без JSON.stringify перетвориться на [object Object]. Web Storage синхронний і блокує головний потік, якщо писати туди великі дані. У приватному режимі Safari та інших браузерах квоти і тривалість життя можуть відрізнятись, тож «працює в мене» варто перевіряти на чистому профілі.

Pitfalls. localStorage and sessionStorage hold strings only, so an object saved without JSON.stringify becomes [object Object]. Web Storage is synchronous and blocks the main thread when large data is written to it. Private browsing modes can change quotas and lifetimes, so a "works on my machine" claim is worth rechecking on a clean profile.

Уточнююче питання. «Що з цього автоматично йде на бекенд?» — лише cookies; решту фронтенд має прочитати й покласти в запит сам.

Follow-up question. "Which of these reaches the backend automatically?" Only cookies; the front end has to read the rest and put it into a request itself.

Швидкий огляд сховищ із консолі DevTools
A quick look at every store from the DevTools console
// cookies, доступні скриптам (HttpOnly тут не буде)
console.log(document.cookie);
// localStorage і sessionStorage як таблиці
console.table({ ...localStorage });
console.table({ ...sessionStorage });
// назви баз IndexedDB (асинхронно)
indexedDB.databases().then(dbs => console.log(dbs.map(d => d.name)));
// cookies visible to scripts (HttpOnly ones are missing here)
console.log(document.cookie);
// localStorage and sessionStorage as tables
console.table({ ...localStorage });
console.table({ ...sessionStorage });
// IndexedDB database names (asynchronous)
indexedDB.databases().then(dbs => console.log(dbs.map(d => d.name)));
🔧 Розбір синтаксису: document.cookie повертає один рядок виду a=1; b=2. { ...localStorage } — spread-оператор копіює пари ключ-значення у звичайний об'єкт, а console.table малює його таблицею. indexedDB.databases() повертає Promise, тому результат читаємо в .then; стрілкова функція d => d.name лишає тільки назви баз.
🔧 Syntax breakdown: document.cookie returns one string shaped like a=1; b=2. { ...localStorage } uses the spread operator to copy the key-value pairs into a plain object, and console.table prints it as a table. indexedDB.databases() returns a Promise, so the result is read in .then; the arrow function d => d.name keeps just the database names.
💡 Простими словами: Cookie браузер несе на сервер сам, усе інше лежить у браузері, поки JS його не забере. Дивитись усе це — у вкладці Application.
💡 In plain words: The browser carries cookies to the server on its own; everything else stays in the browser until JS picks it up. You inspect all of it in the Application tab.
🗣 Як читати вголос Я б пояснив через два питання: хто відправляє дані на сервер і скільки вони живуть. Cookies маленькі, і браузер сам чіпляє їх до запитів. localStorage — більше місця, живе до очищення, але на бекенд сам не йде. sessionStorage такий самий, тільки помирає разом із вкладкою. IndexedDB — це вже справжня асинхронна база для великих даних, типу офлайн-режиму. Усе це розкладене по origin, і я дивлюсь його в Application у DevTools. Коли тестую, завжди питаю себе, чи стан має пережити закриття вкладки, бо від цього залежить, яке сховище правильне.
🗣 How to say it out loud I would explain it through two questions: who sends the data to the server, and how long it lives. Cookies are tiny, and the browser attaches them to requests by itself. localStorage gives you more room and lives until it is cleared, but it never goes to the backend on its own. sessionStorage is the same, except it dies with the tab. IndexedDB is a real asynchronous database for big data, like an offline mode. All of it is split per origin, and I look at it in the Application tab. When I test, I ask whether the state should survive closing the tab, because that tells me which store is right.
26 Що станеться з sessionStorage, коли закрити вкладку, і що — при дублюванні вкладки? What happens to sessionStorage when you close the tab, and what happens when you duplicate it?
СховищеStorage Junior ▶

Що це. sessionStorage — сховище рядків, прив'язане до пари «вкладка + origin». Воно переживає перезавантаження (F5) і переходи всередині вкладки, але коли вкладку закривають, дані зникають. Друга вкладка того самого сайту, відкрита вручну, стартує з порожнім sessionStorage.

What it is. sessionStorage is a string store bound to the pair "tab + origin". It survives a reload (F5) and navigation inside the tab, but once the tab is closed the data is gone. A second tab of the same site that you open by hand starts with an empty sessionStorage.

Навіщо. Звідси класична скарга «у новій вкладці я вже не залогінений»: якщо фронтенд тримає токен у sessionStorage, кожна нова вкладка — нова анонімна сесія. Іноді це свідомий вибір (банк, адмінка), іноді випадковість, і тестер має знати, що саме обіцяє специфікація.

Why it matters. That explains the classic complaint "I am not logged in in the new tab": if the front end keeps the token in sessionStorage, every new tab is a fresh anonymous session. Sometimes that is deliberate (a bank, an admin panel), sometimes an accident, and the tester needs to know what the specification actually promises.

Приклад. Команда «Дублювати вкладку» в Chrome створює нову вкладку з копією sessionStorage на момент дублювання. Далі копії живуть окремо: запис в одній не з'явиться в іншій. Посилання з target="_blank" сучасні браузери відкривають із noopener за замовчуванням, тож така вкладка отримує чистий sessionStorage. Відновлення закритої вкладки через Ctrl+Shift+T у багатьох браузерах повертає і її sessionStorage, що теж варто знати, коли відтворюєш баг.

Example. Chrome's Duplicate tab command creates a new tab with a copy of sessionStorage as of that moment. From then on the copies live apart: a write in one never shows up in the other. Modern browsers open target="_blank" links with noopener by default, so such a tab gets a clean sessionStorage. Restoring a closed tab with Ctrl+Shift+T brings its sessionStorage back in many browsers, which is also worth knowing when you reproduce a bug.

Підводні камені. Не плутай закриття вкладки з закриттям браузера: сесійні cookies без Max-Age живуть до кінця сесії браузера, а з увімкненим «продовжити з місця зупинки» можуть пережити й перезапуск. А sessionStorage зникає саме з вкладкою. Автотести тут теж оманливі: у Playwright кожен newPage() — окрема вкладка з порожнім sessionStorage.

Pitfalls. Do not confuse closing a tab with closing the browser: session cookies without Max-Age last until the browser session ends, and with "continue where you left off" enabled they can even survive a restart. sessionStorage, by contrast, goes away with the tab. Automated tests mislead here as well: in Playwright every newPage() is a separate tab with an empty sessionStorage.

Уточнююче питання. «Як це перевірити без коду?» — Application → Session storage у старій і новій вкладці, порівнявши ключі.

Follow-up question. "How do you check this without code?" Open Application → Session storage in the old and the new tab and compare the keys.

Перевірка в консолі двох вкладок
Checking it in the console of two tabs
// Вкладка A
sessionStorage.setItem('draft', 'hello');
sessionStorage.getItem('draft');   // 'hello'

// Вкладка B, відкрита вручну на тому ж сайті
sessionStorage.getItem('draft');   // null

// Вкладка C, створена через «Дублювати вкладку» з A
sessionStorage.getItem('draft');   // 'hello' (копія, далі живе окремо)
// Tab A
sessionStorage.setItem('draft', 'hello');
sessionStorage.getItem('draft');   // 'hello'

// Tab B, opened by hand on the same site
sessionStorage.getItem('draft');   // null

// Tab C, created with Duplicate tab from A
sessionStorage.getItem('draft');   // 'hello' (a copy, lives on separately)
🔧 Розбір синтаксису: setItem(key, value) записує рядок, getItem(key) читає його або повертає null, якщо ключа немає. Коментарі після // показують, що поверне консоль у кожній вкладці. Той самий код в іншій вкладці звертається до свого, окремого сховища.
🔧 Syntax breakdown: setItem(key, value) writes a string, getItem(key) reads it or returns null when the key is missing. The comments after // show what the console returns in each tab. The same code in another tab talks to its own separate store.
💡 Простими словами: sessionStorage — пам'ять однієї вкладки: F5 її не стирає, закриття вкладки стирає, дублювання робить копію.
💡 In plain words: sessionStorage is the memory of one tab: F5 keeps it, closing the tab wipes it, duplicating the tab makes a copy.
🗣 Як читати вголос Коротко: sessionStorage живе рівно стільки, скільки вкладка. Перезавантаження він переживає, а закриття — ні, і нова вкладка того самого сайту починає з порожнього сховища. Є нюанс, про який часто забувають: якщо вкладку продублювати, браузер копіює туди поточний вміст, але потім копії розходяться. А посилання в нову вкладку відкривається вже з чистим сховищем. Тому коли мені кажуть «у новій вкладці розлогінило», я першим ділом дивлюсь, чи токен не лежить саме тут, і звіряю це з тим, що обіцяє специфікація для такого сценарію.
🗣 How to say it out loud In short, sessionStorage lives exactly as long as the tab does. It survives a reload but not closing, and a new tab of the same site starts with an empty store. There is a detail people forget: if you duplicate the tab, the browser copies the current contents over, but after that the copies drift apart. A link opened in a new tab starts clean. So when someone tells me they got logged out in a new tab, the first thing I check is whether the token sits right here, and then I compare that with what the specification promises.
27 Які атрибути cookie важливі для QA: HttpOnly, Secure, SameSite, Domain, Path, Max-Age? Which cookie attributes matter to QA: HttpOnly, Secure, SameSite, Domain, Path, Max-Age?
СховищеStorage Middle ▶

Що це. Сервер ставить cookie заголовком Set-Cookie, а атрибути після значення кажуть браузеру, кому і коли її показувати. HttpOnly ховає куку від document.cookie, але браузер усе одно шле її в запитах. Secure дозволяє відправку лише по HTTPS. SameSite керує крос-сайтовими запитами: Strict — ніколи, Lax — лише при переході верхнього рівня GET-запитом, None — завжди, але тільки разом із Secure. Domain і Path задають, на які хости і шляхи йде кука, Max-Age чи Expires — скільки вона живе; без них кука сесійна.

What it is. The server sets a cookie with the Set-Cookie header, and the attributes after the value tell the browser who may see it and when. HttpOnly hides the cookie from document.cookie, yet the browser still sends it with requests. Secure allows sending over HTTPS only. SameSite governs cross-site requests: Strict means never, Lax only on a top-level GET navigation, None always, but only together with Secure. Domain and Path set which hosts and paths receive it, Max-Age or Expires set how long it lives; without them it is a session cookie.

Навіщо. Сесійна кука без HttpOnly читається будь-яким XSS-скриптом, без Secure може піти відкритим каналом, а неправильний SameSite ламає вхід через сторонній платіжний або SSO-редірект. Chromium для кук без явного SameSite застосовує Lax.

Why it matters. A session cookie without HttpOnly can be read by any XSS payload, one without Secure may travel over a plain channel, and a wrong SameSite breaks login that comes back from a third-party payment or SSO redirect. Chromium treats a cookie with no explicit SameSite as Lax.

Приклад. Після логіну відкрий Network → запит логіну → Headers і знайди Set-Cookie. Потім Application → Cookies: там колонки HttpOnly, Secure, SameSite, Expires. У Network на вкладці Cookies конкретного запиту видно, які куки пішли, а позначка «show filtered out request cookies» показує заблоковані з поясненням причини.

Example. After logging in, open Network → the login request → Headers and find Set-Cookie. Then Application → Cookies shows the HttpOnly, Secure, SameSite and Expires columns. In Network, the Cookies tab of a single request lists what was sent, and "show filtered out request cookies" reveals the blocked ones with the reason.

Підводні камені. «Тест не бачить куку через document.cookie» ще не означає, що авторизація зламана — HttpOnly і має так працювати, дивись заголовок Cookie запиту. Кука з Domain=example.com піде і на піддомени, а без Domain — лише на точний хост. На локальному http:// стенді кука з Secure може не зберегтися, крім localhost.

Pitfalls. "The test cannot see the cookie through document.cookie" does not prove auth is broken — HttpOnly is meant to work that way, so read the request's Cookie header. A cookie with Domain=example.com also goes to subdomains, while one without Domain goes to the exact host only. On a local http:// stand a Secure cookie may not be stored at all, localhost being the exception.

Уточнююче питання. «Як прочитати HttpOnly-куку в автотесті?» — через API браузера, наприклад context.cookies() у Playwright, а не через JS сторінки.

Follow-up question. "How do you read an HttpOnly cookie in an automated test?" Through the browser API, for example context.cookies() in Playwright, not through page JavaScript.

Відповідь на логін і перевірка в тесті
The login response and a check in a test
HTTP/1.1 200 OK
Set-Cookie: session=8f2c1e9a; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600
Content-Type: application/json
Те саме в Playwright
The same in Playwright
const cookies = await context.cookies();
const session = cookies.find(c => c.name === 'session');
expect(session.httpOnly).toBe(true);
expect(session.secure).toBe(true);
expect(session.sameSite).toBe('Lax');
🔧 Розбір синтаксису: У Set-Cookie спершу йде name=value, далі атрибути через ;. Булеві атрибути (HttpOnly, Secure) пишуться без значення, інші — як Ключ=значення. Max-Age=3600 — секунди, тобто година. У Playwright context.cookies() повертає масив об'єктів з полями httpOnly, secure, sameSite, а find бере потрібну куку за назвою.
🔧 Syntax breakdown: Set-Cookie starts with name=value, then attributes separated by ;. Boolean attributes (HttpOnly, Secure) have no value, the others are Key=value. Max-Age=3600 is in seconds, so one hour. In Playwright context.cookies() returns an array of objects with httpOnly, secure and sameSite fields, and find picks the cookie by name.
💡 Простими словами: HttpOnly ховає куку від скриптів, а не від мережі. Сесійна кука має бути HttpOnly, Secure і з осмисленим SameSite.
💡 In plain words: HttpOnly hides a cookie from scripts, not from the network. A session cookie should be HttpOnly, Secure and have a deliberate SameSite.
🗣 Як читати вголос Для сесійної куки я перевіряю три обов'язкові речі. HttpOnly — щоб її не міг вкрасти скрипт через XSS; при цьому браузер її все одно відправляє, тож порожній document.cookie мене не лякає. Secure — щоб вона ходила лише по HTTPS. І SameSite: Lax як розумний дефолт, None лише в парі з Secure, коли справді потрібні крос-сайтові запити. Далі дивлюсь Domain і Path, щоб кука не витікала на зайві піддомени, і термін життя. Усе це видно в Application і в заголовку Set-Cookie, а заблоковані куки вкладка Network показує разом із причиною блокування.
🗣 How to say it out loud For a session cookie I check three things that are not optional. HttpOnly, so a script injected through XSS cannot steal it; the browser still sends it, so an empty document.cookie does not worry me. Secure, so it only travels over HTTPS. And SameSite: Lax as the sensible default, None only paired with Secure when cross-site requests are genuinely needed. Then I look at Domain and Path, so it does not leak to extra subdomains, and at the lifetime. All of that is visible in Application and in the Set-Cookie header, and Network shows blocked cookies together with the reason.
28 Чому токен у localStorage не долітає до бекенду сам? Why doesn't a token stored in localStorage reach the backend on its own?
СховищеStorage Middle ▶

Що це. Автоматично до запитів браузер додає лише cookies свого домену. Web Storage (localStorage і sessionStorage) — просто сховище для JavaScript, воно ніколи не потрапляє в HTTP-запит без участі коду. Тому фронтенд сам читає токен і кладе його в заголовок, зазвичай Authorization: Bearer <token>.

What it is. The only thing the browser attaches to requests automatically is cookies for their domain. Web Storage (localStorage and sessionStorage) is just a store for JavaScript, and it never lands in an HTTP request unless code puts it there. So the front end reads the token itself and places it in a header, usually Authorization: Bearer <token>.

Навіщо це знати. Звідси кілька типових багів. Запит, відправлений поза застосунком (curl, пряме відкриття URL, картинка чи файл за посиланням), іде без авторизації і отримує 401. Якщо один HTTP-клієнт додає заголовок, а інший ні, то частина запитів «розлогінена». Завантаження файлу через звичайне посилання <a href> не пройде, бо браузер не знає про токен.

Why it matters. Several typical bugs follow from this. A request sent from outside the app (curl, opening a URL directly, an image or a file behind a link) goes out without auth and gets 401. If one HTTP client adds the header and another does not, part of the requests are "logged out". Downloading a file through a plain <a href> link fails, because the browser knows nothing about the token.

Приклад. У Network відкрий запит, що повернув 401, і подивись Request Headers: якщо Authorization відсутній, а в Application → Local storage токен є — проблема у фронтенді, що не додав заголовок, а не в бекенді. Порівняй із запитом, який пройшов, і знайди різницю.

Example. In Network, open the request that returned 401 and look at Request Headers: if Authorization is missing while Application → Local storage does hold the token, the front end forgot the header, not the backend. Compare it with a request that succeeded and find the difference.

Підводні камені. Будь-який скрипт на сторінці, зокрема впроваджений через XSS, читає localStorage так само легко, як і твій застосунок, тож токен там не захищений прапорцем на кшталт HttpOnly. Протилежна помилка — вважати, що cookie-сесія «безпечна повністю»: кука, яку браузер додає сам, відкриває шлях до CSRF, якщо немає SameSite чи CSRF-токена.

Pitfalls. Any script on the page, including one injected through XSS, reads localStorage as easily as your app does, so a token there has no protection like HttpOnly. The opposite mistake is assuming a cookie session is "fully safe": a cookie the browser adds on its own opens the way to CSRF when SameSite or a CSRF token is missing.

Уточнююче питання. «Як тоді протестувати API вручну?» — скопіювати запит із Network як cURL: DevTools включить заголовок Authorization, якщо фронтенд його додав.

Follow-up question. "How do you test the API by hand then?" Copy the request from Network as cURL: DevTools includes the Authorization header if the front end added it.

Що фронтенд мусить зробити сам
What the front end has to do itself
const token = localStorage.getItem('access_token');

await fetch('/api/orders', {
  headers: { Authorization: 'Bearer ' + token },  // без цього рядка — 401
});

// Кука ж іде сама, варто лише дозволити її для крос-origin запиту
await fetch('https://api.shop.test/orders', { credentials: 'include' });
const token = localStorage.getItem('access_token');

await fetch('/api/orders', {
  headers: { Authorization: 'Bearer ' + token },  // without this line: 401
});

// A cookie goes by itself; a cross-origin call just has to allow it
await fetch('https://api.shop.test/orders', { credentials: 'include' });
🔧 Розбір синтаксису: fetch(url, options) — вбудований HTTP-клієнт браузера; об'єкт headers задає заголовки запиту. Шаблонний рядок у зворотних лапках підставляє значення через ${token}. credentials: 'include' каже браузеру додати cookies і до крос-origin запиту (за замовчуванням fetch шле їх лише на свій origin).
🔧 Syntax breakdown: fetch(url, options) is the browser's built-in HTTP client; the headers object sets request headers. The template literal in back quotes inserts the value with ${token}. credentials: 'include' tells the browser to attach cookies to a cross-origin request too (by default fetch only sends them to its own origin).
💡 Простими словами: Cookie браузер несе сам, токен зі сховища несе тільки код. Нема заголовка Authorization — нема авторизації.
💡 In plain words: The browser carries a cookie itself; a token from storage is carried only by code. No Authorization header, no auth.
🗣 Як читати вголос Бо браузер автоматично додає до запиту тільки куки. localStorage — це просто сховище для JavaScript, і поки код фронтенду не прочитає токен і не покладе його в заголовок Authorization, сервер про нього не дізнається. На практиці це означає, що коли я бачу 401, то відкриваю запит у Network і дивлюсь, чи є там цей заголовок, а в Application — чи є сам токен. Звідси ж баги з завантаженням файлів за звичайним посиланням. І я завжди згадую безпеку: токен у localStorage доступний будь-якому скрипту, тому XSS там значно небезпечніший.
🗣 How to say it out loud Because the browser only attaches cookies to a request on its own. localStorage is just a store for JavaScript, and until the front-end code reads the token and puts it into the Authorization header, the server never hears about it. In practice, when I see a 401, I open the request in Network and check whether that header is there, and in Application whether the token itself exists. The same thing explains bugs with downloading files through a plain link. And I always mention security: a token in localStorage is readable by any script, so an XSS hole there is far more dangerous.
29 «Мене розлогінює у новій вкладці» — у якому порядку це перевіряти? "It logs me out in a new tab" — in what order do you investigate it?
СховищеStorage Senior ▶

Що це. Скарга описує симптом, а не причину. Причина майже завжди в тому, де застосунок тримає ознаку сесії і як її ділять вкладки. Тому порядок такий: відтворити на чистому профілі (залогінитись і відкрити сайт у новій вкладці), знайти, де живе ознака сесії — cookie, localStorage чи sessionStorage, порівняти це сховище у старій і новій вкладці, звірити з тим, що обіцяє специфікація щодо тривалості сесії, і в тікеті описати розрив між обіцяним і фактичним.

What it is. The complaint describes a symptom, not a cause. The cause is almost always where the app keeps the session marker and how tabs share it. So the order is: reproduce on a clean profile (log in, then open the site in a new tab), find where the session marker lives — cookie, localStorage or sessionStorage, compare that storage in the old and new tab, check the finding against what the spec promises about session lifetime, and write the gap between promised and actual into the ticket.

Навіщо саме так. Без другого кроку тікет перетворюється на «зламався логін», хоча причина — вибір сховища, і виправляти її буде інша людина: фронтенд, а не бекенд. Чистий профіль потрібен, бо старі куки, розширення чи Service Worker маскують проблему.

Why this order. Skip the second step and the ticket becomes "login is broken", when the cause is the storage choice and a different person will fix it: a front-end developer, not the backend. A clean profile matters because old cookies, extensions or a Service Worker can mask the problem.

Приклад із розслідування. Токен лежить у sessionStorage — нова вкладка стартує порожньою, і це поведінка «за дизайном», яку треба або прийняти, або змінити у вимогах. Інший варіант: сесія в cookie, але з Path=/app, а нова вкладка відкривається на /. Третій — гонка оновлення токена: дві вкладки одночасно міняють refresh-токен, сервер з ротацією визнає перший використаним і відкликає сесію. Такі вкладки мають домовлятися через подію storage або BroadcastChannel.

Example from an investigation. The token sits in sessionStorage — a new tab starts empty, which is behaviour "by design" that must be accepted or changed in the requirements. Another variant: the session is a cookie, but with Path=/app, while the new tab opens on /. A third: a token refresh race, where two tabs rotate the refresh token at once and a server with rotation treats the first as reused and revokes the session. Tabs like that have to coordinate through the storage event or a BroadcastChannel.

Підводні камені. Не зупиняйся на першій знахідці: переконайся, що нова вкладка справді того самого origin (www проти без www — різні сховища). Перевір і режим відкриття: посилання з target="_blank", дубль вкладки і ручне відкриття поводяться по-різному для sessionStorage.

Pitfalls. Do not stop at the first finding: make sure the new tab really is on the same origin (www versus no www means separate stores). Check how the tab was opened, too: a target="_blank" link, a duplicated tab and a tab opened by hand behave differently for sessionStorage.

Уточнююче питання. «Хто має вирішити, чи це баг?» — продукт і безпека: для банку розлогін у новій вкладці може бути вимогою.

Follow-up question. "Who decides whether it is a bug?" Product and security: for a bank, logging out in a new tab may be a requirement.

Де шукати ознаку сесії — виконати в обох вкладках
Where to find the session marker — run in both tabs
// 1. Що є в сховищах цього origin
console.log(location.origin);
console.table({ ...localStorage });
console.table({ ...sessionStorage });

// 2. Чи сповіщають вкладки одна одну про новий токен
new BroadcastChannel('auth').onmessage = e => console.log('інша вкладка:', e.data);
addEventListener('storage', e => console.log('змінено ключ', e.key));
// 1. What the stores of this origin hold
console.log(location.origin);
console.table({ ...localStorage });
console.table({ ...sessionStorage });

// 2. Do the tabs tell each other about a new token
new BroadcastChannel('auth').onmessage = e => console.log('other tab:', e.data);
addEventListener('storage', e => console.log('changed key', e.key));
🔧 Розбір синтаксису: location.origin показує схему, хост і порт — так ти впевнишся, що вкладки на одному origin. new BroadcastChannel('auth') відкриває канал з назвою auth, а onmessage ловить повідомлення інших вкладок у цьому каналі. Подія storage спрацьовує в інших вкладках, коли одна з них змінює localStorage; e.key — назва зміненого ключа.
🔧 Syntax breakdown: location.origin shows scheme, host and port, so you can be sure both tabs are on one origin. new BroadcastChannel('auth') opens a channel named auth, and onmessage catches messages from other tabs on that channel. The storage event fires in the other tabs when one of them changes localStorage; e.key is the name of the changed key.
💡 Простими словами: Спершу знайди, де лежить сесія, потім порівняй вкладки, потім — спеку. У тікеті — розрив між обіцяним і фактичним.
💡 In plain words: First find where the session lives, then compare the tabs, then the spec. The ticket describes the gap between promised and actual.
🗣 Як читати вголос Я б не починав із «логін зламаний». Спершу відтворюю на чистому профілі, щоб прибрати старі куки і розширення. Далі головне — з'ясувати, де живе сесія: кука, localStorage чи sessionStorage. Потім порівнюю це сховище в старій і новій вкладці, і картина зазвичай стає очевидною. Якщо токен у sessionStorage, то це не збій, а наслідок архітектурного рішення, і тоді я звіряюсь зі специфікацією, чи так задумано. Буває й хитріше — гонка оновлення токена між вкладками. У тікет я пишу розрив між обіцяним і фактичним, щоб він потрапив до правильної людини.
🗣 How to say it out loud I would not start with "login is broken". First I reproduce it on a clean profile, to get rid of old cookies and extensions. Then the key step is finding out where the session lives: a cookie, localStorage or sessionStorage. After that I compare that store in the old and the new tab, and usually the picture becomes obvious. If the token is in sessionStorage, this is not a glitch but the result of a design decision, so I check the spec to see whether it was intended. Sometimes it is trickier, like a token refresh race between tabs. In the ticket I describe the gap between promised and actual, so it reaches the right person.
30 UI-логін у кожному тесті — найповільніша частина сюїти. Як прибрати його безпечно? A UI login in every test is the slowest part of the suite. How do you remove it safely?
СховищеStorage Senior ▶

Що це. Замість проходити форму входу в кожному тесті сесію отримують один раз — найкраще одним API-викликом — і підкладають браузеру готовою. У Playwright для цього є окремий setup-проєкт, який зберігає стан у файл через storageState (cookies і localStorage), а решта тестів стартує вже залогіненою.

What it is. Instead of going through the login form in every test, you obtain a session once — ideally with a single API call — and hand it to the browser ready-made. Playwright has a separate setup project for this, which saves the state to a file via storageState (cookies and localStorage), and all other tests start already logged in.

Навіщо і де компроміс. Підкладена сесія економить хвилини на кожному прогоні, але тоді сам логін не перевіряє ніхто, а на ньому мовчки тримається вся решта сюїти. Тому обов'язково лишається окремий тест на справжній UI-логін, включно з неправильним паролем і блокуванням. Це і є найбезпечніше рішення: швидкість для більшості тестів плюс чесна перевірка самого входу.

Why, and where the trade-off is. An injected session saves minutes on every run, but then nobody checks the login itself, and the rest of the suite silently depends on it. So a separate test for the real UI login must stay, including a wrong password and a locked account. That is the safest answer: speed for most tests plus an honest check of the entrance.

Приклад. Файл auth.setup.ts логіниться через API, записує стан у .auth/user.json, а в конфігурації проєкти залежать від setup і вказують цей файл. Для різних ролей роби окремі файли: admin.json, buyer.json.

Example. An auth.setup.ts file logs in through the API and writes the state to .auth/user.json; in the config, the projects depend on setup and point at that file. Use separate files for separate roles: admin.json, buyer.json.

Підводні камені. Паралельні воркери з одним акаунтом псують одне одному дані (кошик, налаштування) — дай кожному воркеру свого користувача або тримай тести на спільному акаунті лише на читання. Файл стану містить живий токен: додай .auth/ у .gitignore, а логін і пароль бери зі змінних середовища CI. Якщо сесія живе недовго, стан протухне посеред довгого прогону. І пам'ятай, що sessionStorage у storageState не потрапляє, тож застосунок, який тримає токен там, потребує іншого підходу.

Pitfalls. Parallel workers sharing one account corrupt each other's data (cart, settings) — give each worker its own user, or keep tests on a shared account read-only. The state file holds a live token: put .auth/ in .gitignore and take the username and password from CI environment variables. If the session is short-lived, the state expires in the middle of a long run. And remember that sessionStorage does not go into storageState, so an app that keeps its token there needs a different approach.

Уточнююче питання. «А чому не логін через UI один раз у beforeAll?» — бо він усе одно повільний і крихкий, а API-логін дає ту саму сесію за частку секунди.

Follow-up question. "Why not log in through the UI once in beforeAll?" Because it is still slow and flaky, while an API login gives you the same session in a fraction of a second.

auth.setup.ts і фрагмент playwright.config.ts
auth.setup.ts and a slice of playwright.config.ts
// auth.setup.ts
import { test as setup } from '@playwright/test';

setup('логін покупця через API', async ({ request }) => {
  await request.post('/api/login', {
    data: { email: process.env.BUYER_EMAIL, password: process.env.BUYER_PASSWORD },
  });
  // кука сесії з відповіді вже лежить у request-контексті
  await request.storageState({ path: '.auth/buyer.json' });
});

// playwright.config.ts
projects: [
  { name: 'setup', testMatch: /auth\.setup\.ts/ },
  { name: 'chromium', dependencies: ['setup'],
    use: { storageState: '.auth/buyer.json' } },
],
// auth.setup.ts
import { test as setup } from '@playwright/test';

setup('buyer logs in through the API', async ({ request }) => {
  await request.post('/api/login', {
    data: { email: process.env.BUYER_EMAIL, password: process.env.BUYER_PASSWORD },
  });
  // the session cookie from the response now sits in the request context
  await request.storageState({ path: '.auth/buyer.json' });
});

// playwright.config.ts
projects: [
  { name: 'setup', testMatch: /auth\.setup\.ts/ },
  { name: 'chromium', dependencies: ['setup'],
    use: { storageState: '.auth/buyer.json' } },
],
🔧 Розбір синтаксису: test as setup — імпорт із перейменуванням, щоб setup-крок читався окремо від тестів. Фікстура request — HTTP-клієнт Playwright, який зберігає cookies з відповідей. request.storageState({ path }) записує їх у JSON-файл. У конфігурації dependencies: ['setup'] гарантує, що setup виконається першим, а use.storageState підкладає файл кожному новому контексту. process.env читає секрети зі змінних середовища.
🔧 Syntax breakdown: test as setup is a renamed import, so the setup step reads separately from the tests. The request fixture is Playwright's HTTP client, which keeps cookies from responses. request.storageState({ path }) writes them to a JSON file. In the config, dependencies: ['setup'] makes setup run first, and use.storageState feeds the file to every new context. process.env reads secrets from environment variables.
💡 Простими словами: Логінься один раз через API, підкладай сесію всім тестам, але лиши один чесний тест на сам UI-логін.
💡 In plain words: Log in once through the API, feed the session to every test, but keep one honest test of the UI login itself.
🗣 Як читати вголос Я б отримав сесію одним API-викликом у setup-проєкті, зберіг її через storageState у файл і підклав усім тестам — це прибирає хвилини з кожного прогону. Але я чітко проговорюю компроміс: тепер ніхто не перевіряє сам логін, тому окремий тест на UI-вхід із позитивним і негативними сценаріями лишається обов'язково. Далі думаю про паралельність: у кожного воркера свій користувач, інакше тести зламають одне одному кошик. І про секрети: паролі тільки зі змінних середовища, а файл зі станом — у gitignore, бо там лежить живий токен.
🗣 How to say it out loud I would get the session with one API call in a setup project, save it to a file with storageState and feed it to every test, which takes minutes off each run. But I state the trade-off clearly: now nobody checks the login itself, so a separate UI login test with positive and negative cases has to stay. Then I think about parallelism: every worker gets its own user, or the tests will wreck each other's cart. And about secrets: passwords come only from environment variables, and the state file goes into gitignore, because it holds a live token.
31 Що таке service worker, як виглядає його життєвий цикл і що означає стан waiting? What is a service worker, what does its lifecycle look like, and what does the waiting state mean?
СховищеStorage Middle ▶

Що це. Service worker — скрипт, який браузер запускає окремо від сторінки. Він стоїть між сайтом і мережею: перехоплює запити подією fetch і може відповідати з кешу, завдяки чому працюють офлайн-режим і push-сповіщення. Життєвий цикл: реєстрація → install (кешування файлів) → installed, тобто waiting → activate → activated, після чого воркер керує сторінками.

What it is. A service worker is a script the browser runs separately from the page. It sits between the site and the network: it intercepts requests through the fetch event and can answer from a cache, which is what makes offline mode and push notifications work. The lifecycle: registration → install (caching files) → installed, that is waiting → activate → activated, after which the worker controls pages.

Навіщо тестеру стан waiting. Коли після релізу завантажився новий воркер, а старий ще контролює відкриті вкладки, новий чекає у стані waiting. Він активується лише тоді, коли закриються всі вкладки, якими керує старий. F5 вкладку не закриває, тож «оновив, а змін нема» — це стан воркера, а не баг релізу. Користувач може тижнями бачити стару версію, якщо не закриває браузер.

Why a tester cares about waiting. When a new worker has been downloaded after a release while the old one still controls open tabs, the new one waits in the waiting state. It activates only once every tab controlled by the old worker has been closed. F5 does not close the tab, so "I refreshed and nothing changed" is the worker's state, not a release bug. A user who never closes the browser may see the old version for weeks.

Приклад. DevTools → Application → Service workers показує активний воркер і новий у статусі «waiting to activate» з посиланням skipWaiting. Галочка Update on reload змушує оновлювати воркер при кожному перезавантаженні, а Bypass for network відключає його перехоплення. Застосунок може сам викликати self.skipWaiting() і clients.claim(), щоб новий воркер одразу взяв керування, і часто показує банер «Доступна нова версія».

Example. DevTools → Application → Service workers shows the active worker and the new one as "waiting to activate" with a skipWaiting link. The Update on reload checkbox forces an update on every reload, and Bypass for network turns off its interception. The app itself can call self.skipWaiting() and clients.claim() so the new worker takes over at once, and it often shows a "New version available" banner.

Підводні камені. skipWaiting без перезавантаження сторінки небезпечний: стара сторінка може запросити файли, яких нова версія кешу вже не має. Для чесного відтворення в тесті використовуй чистий профіль, бо DevTools із «Update on reload» маскує проблему, яку бачать користувачі.

Pitfalls. skipWaiting without reloading the page is risky: the old page may request files the new cache version no longer has. For an honest reproduction use a clean profile, because DevTools with "Update on reload" hides the problem users actually see.

Уточнююче питання. «Як перевірити, що новий воркер активувався?» — у консолі порівняти navigator.serviceWorker.controller і стан реєстрації або подивитись у Application.

Follow-up question. "How do you check that the new worker has activated?" In the console compare navigator.serviceWorker.controller with the registration state, or look in Application.

Стан воркерів із консолі сторінки
Worker states from the page console
const reg = await navigator.serviceWorker.getRegistration();
console.log('активний:', reg.active?.state);        // 'activated'
console.log('чекає:', reg.waiting?.state);          // 'installed' = waiting
console.log('керує сторінкою:', !!navigator.serviceWorker.controller);

// у sw.js: не чекати закриття старих вкладок
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
const reg = await navigator.serviceWorker.getRegistration();
console.log('active:', reg.active?.state);          // 'activated'
console.log('waiting:', reg.waiting?.state);        // 'installed' = waiting
console.log('controls page:', !!navigator.serviceWorker.controller);

// in sw.js: do not wait for the old tabs to close
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
🔧 Розбір синтаксису: getRegistration() повертає реєстрацію воркера для поточної сторінки; її поля active, waiting, installing — воркери в різних станах. ?. — optional chaining: якщо воркера немає, отримаєш undefined, а не помилку. !! перетворює значення на true/false. У sw.js self — сам воркер, e.waitUntil() тримає фазу activate, доки clients.claim() не візьме керування відкритими сторінками.
🔧 Syntax breakdown: getRegistration() returns the worker registration for the current page; its active, waiting and installing fields are workers in different states. ?. is optional chaining: if there is no worker you get undefined instead of an error. !! turns a value into true/false. In sw.js, self is the worker itself, and e.waitUntil() keeps the activate phase open until clients.claim() takes over the open pages.
💡 Простими словами: Новий service worker чекає, поки закриються всі вкладки зі старим. Перезавантаження вкладки цього не робить.
💡 In plain words: A new service worker waits until every tab with the old one is closed. Reloading a tab does not do that.
🗣 Як читати вголос Service worker — це проксі між сторінкою і мережею, який живе окремо від вкладки і відповідає з кешу. Його цикл: встановлення, потім очікування, потім активація. Саме очікування й дає найбільше дивних багів після релізу: новий воркер уже завантажився, але поки хоч одна вкладка зі старим відкрита, він не активується, а звичайне перезавантаження вкладку не закриває. Тому користувач бачить стару версію. Я перевіряю це в Application у розділі Service workers і тестую на чистому профілі, бо галочка оновлення при перезавантаженні в DevTools ховає реальну поведінку.
🗣 How to say it out loud A service worker is a proxy between the page and the network; it lives apart from the tab and can answer from a cache. Its cycle is install, then waiting, then activate. The waiting part causes most of the odd bugs after a release: the new worker has already downloaded, but while even one tab with the old worker is open it will not activate, and a normal reload does not close the tab. So the user keeps seeing the old version. I check it under Service workers in the Application tab and test on a clean profile, because the update-on-reload checkbox in DevTools hides the real behaviour.
▶ 5 9
32 Що вимірює сегмент Waiting (TTFB) у рядку запиту і що означає, коли він довгий? What does the Waiting (TTFB) segment of a request measure, and what does it mean when it is long?
МережаNetwork Junior ▶

Що це. TTFB (Time To First Byte) — час від моменту, коли браузер відправив запит, до моменту, коли прийшов перший байт відповіді. У Chrome DevTools це сегмент Waiting for server response на вкладці Timing вибраного запиту. У нього входить дорога запиту до сервера, вся робота бекенду (роутинг, запити до бази, виклики інших сервісів) і дорога першого байта назад.

What it is. TTFB (Time To First Byte) is the time from the moment the browser sent the request until the first byte of the response arrived. In Chrome DevTools it is the Waiting for server response segment on the Timing tab of the selected request. It covers the trip to the server, all the backend work (routing, database queries, calls to other services) and the trip of the first byte back.

Навіщо. TTFB — це частка сервера в загальній тривалості. Якщо запит іде 3 секунди, з яких 2,8 — Waiting, то стискати картинки чи переписувати фронт марно: проблема на бекенді або між ним і клієнтом. Такий висновок одразу підказує, кому віддавати баг і які логи просити.

Why it matters. TTFB is the server's share of the total duration. If a request takes 3 seconds and 2.8 of them are Waiting, compressing images or rewriting the frontend is pointless: the problem sits in the backend or between it and the client. That conclusion tells you straight away who owns the bug and which logs to ask for.

Приклад. Пошук товарів відкривається повільно. На вкладці Network клацаєш рядок /api/search?q=phone, відкриваєш Timing і бачиш: Waiting 2,4 с, Content Download 12 мс. У баг-репорт ідуть URL, параметри, скріншот Timing і точний час запиту, щоб бекенд знайшов його в трейсах. Для перевірки з терміналу підійде curl -w із полем time_starttransfer.

Example. Product search opens slowly. On the Network tab you click the /api/search?q=phone row, open Timing and see Waiting 2.4 s and Content Download 12 ms. The bug report gets the URL, the parameters, a Timing screenshot and the exact request time, so the backend team can find it in their traces. From a terminal, curl -w with the time_starttransfer field gives the same number.

Підводні камені. Один вимір нічого не доводить: перший запит може чекати на холодний кеш чи старт контейнера, тож повтори кілька разів. Throttling у DevTools штучно додає затримку, і TTFB зросте без жодної вини сервера. А ще TTFB документа і TTFB окремого API-виклику — різні числа, не змішуй їх у звіті.

Pitfalls. One measurement proves nothing: the first request may be waiting on a cold cache or a container start, so repeat it a few times. DevTools throttling adds latency on purpose, and TTFB grows without the server being at fault. Also, the TTFB of the document and that of a single API call are different numbers, so keep them apart in a report.

Уточнююче питання. «А якщо довгий не Waiting, а Stalled?» — тоді запит стояв у черзі браузера, наприклад через ліміт з'єднань на HTTP/1.1, і сервер тут ні до чого.

Follow-up question. "What if it is Stalled rather than Waiting that is long?" Then the request sat in the browser's own queue, for instance because of the per-host connection limit on HTTP/1.1, and the server has nothing to do with it.

Виміряти TTFB з терміналу
Measuring TTFB from a terminal
curl -s -o /dev/null \
  -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
  'https://shop.example.com/api/search?q=phone'

# dns=0.012 connect=0.041 tls=0.093 ttfb=2.431 total=2.447
🔧 Розбір синтаксису: -s вимикає прогрес-бар, -o /dev/null викидає тіло відповіді, -w друкує шаблон зі змінними curl після завершення запиту. Усі %{time_*} рахуються від старту запиту накопичувально, тому time_starttransfer — це момент першого байта, а різниця між ним і time_appconnect приблизно дорівнює Waiting з DevTools. Рядок з # — приклад виводу.
🔧 Syntax breakdown: -s hides the progress bar, -o /dev/null throws the body away, -w prints a template of curl variables once the request finishes. All the %{time_*} values are cumulative from the start, so time_starttransfer is the first-byte moment, and its difference from time_appconnect roughly equals Waiting in DevTools. The line starting with # is sample output.
💡 Простими словами: Довгий Waiting — іди до бекенду; довгий Content Download — дивись на розмір відповіді.
💡 In plain words: A long Waiting points at the backend; a long Content Download points at the response size.
🗣 Як читати вголос TTFB — це скільки браузер чекав від відправки запиту до першого байта відповіді. У DevTools я відкриваю рядок запиту, вкладку Timing і дивлюсь на сегмент Waiting. Якщо саме він з'їдає майже весь час, я не йду до фронтенду, а несу бекенду конкретний запит, час і параметри, щоб вони знайшли його в трейсах. Перед тим як писати баг, я повторюю запит кілька разів і вимикаю throttling, бо холодний кеш або штучна затримка легко дають хибну картину. Ну і для перевірки поза браузером у мене завжди під рукою curl з прапорцем w, який друкує всі таймінги.
🗣 How to say it out loud TTFB is how long the browser waited between sending the request and getting the first byte back. In DevTools I open the request, go to Timing and look at the Waiting segment. If that one eats nearly all the time, I do not go to the frontend people — I bring the backend a concrete request, a timestamp and the parameters so they can find it in their traces. Before filing, I repeat the call a few times and turn throttling off, because a cold cache or artificial latency can easily paint the wrong picture. And to check it outside the browser I just use curl with the w flag.
33 У якому порядку йдуть фази одного запиту у waterfall і що кожна з них каже тестеру? In what order do the phases of one request appear in the waterfall, and what does each one tell a tester?
МережаNetwork Middle ▶

Що це. Waterfall у вкладці Network розкладає кожен запит на фази, і вони завжди йдуть однаково: Queueing (запит чекає на вільне з'єднання або пріоритет), DNS lookup (резолв домену в IP), Initial connection і SSL (TCP- та TLS-рукостискання), Request sent (запит пішов у мережу), Waiting (TTFB — сервер готує відповідь), Content Download (тіло відповіді приїжджає). Детальний розклад видно на вкладці Timing конкретного запиту.

What it is. The waterfall on the Network tab splits every request into phases, and they always come in the same order: Queueing (the request waits for a free connection or its priority), DNS lookup (the domain resolves to an IP), Initial connection and SSL (the TCP and TLS handshakes), Request sent (the request goes out), Waiting (TTFB — the server prepares the response) and Content Download (the body arrives). The Timing tab of a single request shows the detailed breakdown.

Навіщо. Порядок фаз — це карта відповідальності. Довгі DNS чи з'єднання вказують на мережу, TLS або далекий регіон. Довгий Waiting — на бекенд. Довгий Download — на розмір відповіді чи повільний канал. Довгий Queueing або Stalled — на сам браузер: ліміт з'єднань, пріоритети, блокування іншими запитами.

Why it matters. The phase order is a map of responsibility. Long DNS or connection phases point at the network, TLS or a distant region. A long Waiting points at the backend. A long Download points at response size or a slow link. Long Queueing or Stalled points at the browser itself: connection limits, priorities, other requests blocking the way.

Приклад. Сторінка каталогу вантажиться 6 секунд. У waterfall видно, що перші запити йдуть швидко, а 20 картинок з img.cdn.example мають довгий Stalled. Відповідь у заголовках: CDN віддає їх по HTTP/1.1, а браузер тримає до шести з'єднань на хост, тож решта чекає. Баг формулюється як «картинки каталогу стоять у черзі до 3 с через HTTP/1.1 на CDN», а не «сайт гальмує».

Example. The catalogue page takes 6 seconds. The waterfall shows the first requests are quick, but 20 images from img.cdn.example have a long Stalled phase. The headers explain it: the CDN serves them over HTTP/1.1 and the browser keeps at most six connections per host, so the rest wait. The bug becomes "catalogue images queue for up to 3 s because the CDN uses HTTP/1.1", not "the site is slow".

Підводні камені. Для повторного запиту до того самого хоста DNS і Initial connection зникають — з'єднання перевикористовується, це не помилка. Запит із кешу не має мережевих фаз зовсім. Сумарна тривалість сторінки — не сума всіх рядків, бо запити йдуть паралельно; дивись на критичний ланцюжок.

Pitfalls. A repeat request to the same host has no DNS or Initial connection phase because the connection is reused, and that is not a bug. A request served from cache has no network phases at all. The page's total time is not the sum of all rows, since requests run in parallel; look at the critical chain instead.

Уточнююче питання. «Де взяти ці числа для звіту?» — навести на смужку у waterfall або експортувати HAR, там фази лежать у полі timings.

Follow-up question. "Where do you get these numbers for a report?" Hover over the bar in the waterfall or export a HAR file, where the phases sit in the timings field.

Фази запиту в HAR-файлі (мс)
Request phases in a HAR file (ms)
"timings": {
  "blocked": 2890.4,
  "dns": -1,
  "connect": -1,
  "ssl": -1,
  "send": 0.3,
  "wait": 84.2,
  "receive": 311.7
}
🔧 Розбір синтаксису: Це фрагмент JSON з HAR-експорту (Network → стрілка Export HAR). blocked відповідає Queueing і Stalled, dns, connect, ssl — встановленню з'єднання, send — Request sent, wait — TTFB, receive — Content Download. Значення -1 означає, що фази не було: тут з'єднання перевикористане, а весь час з'їла черга.
🔧 Syntax breakdown: This is a JSON fragment from a HAR export (Network → the Export HAR arrow). blocked maps to Queueing and Stalled, dns, connect and ssl to setting up the connection, send to Request sent, wait to TTFB, receive to Content Download. A value of -1 means the phase did not happen: here the connection was reused and the queue ate the time.
💡 Простими словами: Знайди найдовшу фазу — вона й підкаже, чий це баг: браузера, мережі, сервера чи розміру відповіді.
💡 In plain words: Find the longest phase — it tells you whose bug it is: the browser's, the network's, the server's or the payload's.
🗣 Як читати вголос Я читаю waterfall зліва направо: спершу черга в браузері, далі DNS, з'єднання і TLS, потім запит пішов, далі очікування першого байта і нарешті завантаження тіла. Мені важливий не сам список, а те, яка фаза найдовша, бо це і є адресат бага. Довга черга — це проблема браузера чи кількості з'єднань, довге очікування — бекенд, довге скачування — завеликий респонс. Коли я пишу звіт, то прикладаю HAR, щоб розробник побачив ті самі таймінги у себе. І я не лякаюсь, якщо в повторного запиту немає DNS: з'єднання просто перевикористалось.
🗣 How to say it out loud I read the waterfall left to right: first the browser queue, then DNS, the connection and TLS, then the request goes out, then waiting for the first byte and finally downloading the body. What matters to me is not the list itself but which phase is longest, because that is who the bug belongs to. A long queue is the browser or the connection count, long waiting is the backend, a long download is an oversized response. When I file it I attach a HAR so the developer sees the same timings. And I do not panic when a repeat request has no DNS phase; the connection was just reused.
34 Які HTTP-заголовки має знати тестер і за що відповідає Content-Type? Which HTTP headers should a tester know, and what is Content-Type responsible for?
МережаNetwork Junior ▶

Що це. Content-Type описує формат тіла, яке реально лежить у цьому запиті чи відповіді: application/json, text/html; charset=utf-8, multipart/form-data; boundary=.... Його плутають з Accept, але Accept — лише побажання клієнта, у якому форматі він хоче отримати відповідь, а Content-Type — факт про те, що надіслано.

What it is. Content-Type describes the format of the body that is actually in this request or response: application/json, text/html; charset=utf-8, multipart/form-data; boundary=.... People confuse it with Accept, but Accept is only the client's wish about the response format, while Content-Type is a fact about what was sent.

Навіщо. Окрім нього, тестер щодня читає: Authorization (токен), Cookie і Set-Cookie (сесія), Cache-Control, ETag і Last-Modified (кешування), Location (куди веде редирект), Origin та Access-Control-* (CORS), Content-Encoding (стиснення). Більшість «незрозумілих» багів пояснюється одним із цих рядків.

Why it matters. Beyond that, a tester reads these every day: Authorization (the token), Cookie and Set-Cookie (the session), Cache-Control, ETag and Last-Modified (caching), Location (where a redirect goes), Origin and Access-Control-* (CORS), Content-Encoding (compression). Most "mysterious" bugs are explained by one of those lines.

Приклад. Фронт відправляє форму, а API повертає 415 Unsupported Media Type. У Request Headers бачиш Content-Type: text/plain, хоча тіло — JSON: хтось передав у fetch рядок без заголовка. Інший варіант: сервер віддає JSON з Content-Type: text/html, і клієнтський код, який перевіряє тип, падає на парсингу.

Example. The frontend submits a form and the API returns 415 Unsupported Media Type. Request Headers show Content-Type: text/plain although the body is JSON: someone passed a string to fetch without setting the header. The mirror case: the server returns JSON labelled Content-Type: text/html, and client code that checks the type fails while parsing.

Підводні камені. Назви заголовків нечутливі до регістру, а в HTTP/2 DevTools показує їх малими літерами — це нормально. Content-Length каже про розмір, а Content-Encoding: gzip — про стиснення, а не про формат. Для multipart/form-data не виставляй тип вручну в автотесті: без boundary сервер не розбере тіло.

Pitfalls. Header names are case-insensitive, and over HTTP/2 DevTools shows them in lower case — that is normal. Content-Length is about size and Content-Encoding: gzip is about compression, not format. For multipart/form-data, do not set the type by hand in an automated test: without the boundary the server cannot parse the body.

Уточнююче питання. «Як подивитися заголовки без браузера?» — curl -i показує їх разом із тілом, curl -I робить HEAD-запит і показує лише заголовки.

Follow-up question. "How do you see headers without a browser?" curl -i prints them together with the body, and curl -I sends a HEAD request and prints only the headers.

Запит і відповідь із ключовими заголовками
A request and response with the key headers
curl -i -X POST 'https://api.example.com/orders' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H 'Authorization: Bearer eyJhbGciOi...' \
  -d '{"productId": 42, "qty": 1}'

HTTP/2 201
content-type: application/json; charset=utf-8
location: /orders/9051
cache-control: no-store
🔧 Розбір синтаксису: -i додає до виводу рядок статусу і заголовки відповіді, -X POST задає метод, кожен -H — один заголовок запиту, -d — тіло. Зворотна скісна риска в кінці рядка переносить команду. Нижче — відповідь: 201 з location на створений ресурс, content-type описує тіло відповіді, cache-control: no-store забороняє його кешувати.
🔧 Syntax breakdown: -i adds the status line and response headers to the output, -X POST sets the method, each -H is one request header, -d is the body. The trailing backslash continues the command on the next line. Below is the response: 201 with a location pointing to the new resource, content-type describing the response body and cache-control: no-store forbidding caching.
💡 Простими словами: Accept — «що я хочу отримати», Content-Type — «що я насправді надіслав».
💡 In plain words: Accept means "what I would like back", Content-Type means "what I actually sent".
🗣 Як читати вголос Content-Type — це заголовок, який чесно каже, в якому форматі лежить тіло саме цього повідомлення, наприклад JSON чи multipart. Його часто плутають з Accept, але Accept — це лише прохання клієнта, що він хоче отримати назад. Коли я бачу 415, першим ділом відкриваю Request Headers і перевіряю, що фронт справді надіслав application/json. Крім нього я постійно дивлюсь на Authorization, куки, Cache-Control, Location при редиректах і на заголовки CORS. А для швидкої перевірки поза браузером використовую curl з ключем i, щоб бачити заголовки разом з тілом.
🗣 How to say it out loud Content-Type is the header that honestly says what format the body of this particular message is in, JSON or multipart for example. People mix it up with Accept, but Accept is only the client asking for what it wants back. When I see a 415, the first thing I open is Request Headers to check that the frontend really sent application/json. Beyond that I keep an eye on Authorization, the cookies, Cache-Control, Location on redirects and the CORS headers. And for a quick check outside the browser I use curl with the i flag so I see the headers together with the body.
35 Які бувають групи статус-кодів і що означає 304 Not Modified? What groups of status codes are there, and what does 304 Not Modified mean?
МережаNetwork Junior ▶

Що це. Статус-коди діляться на п'ять груп: 1xx — інформаційні (наприклад, 101 при переході на WebSocket), 2xx — успіх (200, 201, 204), 3xx — перенаправлення і кеш (301, 302, 304), 4xx — помилка клієнта (400, 401, 403, 404, 409, 422, 429), 5xx — помилка сервера (500, 502, 503, 504). 304 Not Modified означає: ресурс не змінився, бери свою копію з кешу, тіла у відповіді немає.

What it is. Status codes fall into five groups: 1xx informational (for instance 101 when switching to WebSocket), 2xx success (200, 201, 204), 3xx redirects and cache (301, 302, 304), 4xx client error (400, 401, 403, 404, 409, 422, 429) and 5xx server error (500, 502, 503, 504). 304 Not Modified means the resource has not changed, use your cached copy; the response carries no body.

Навіщо. Група коду одразу підказує, кому адресувати баг. 401 — сервер не знає, хто ти (немає або прострочений токен); 403 — знає, але не пускає. 502 і 504 зазвичай вказують на проксі чи балансувальник перед застосунком, а не на сам код.

Why it matters. The group tells you right away who the bug belongs to. 401 means the server does not know who you are (missing or expired token); 403 means it knows and still refuses. 502 and 504 usually point at a proxy or load balancer in front of the application rather than at the code itself.

Приклад. Браузер уже має app.css з ETag: "a1b2". При наступному завантаженні він шле If-None-Match: "a1b2", сервер порівнює й відповідає 304 без тіла. У Network у такого рядка статус 304 і крихітний розмір — це лише заголовки. Якщо ж у колонці Size написано (memory cache) або (disk cache) зі статусом 200, запит у мережу взагалі не йшов.

Example. The browser already holds app.css with ETag: "a1b2". On the next load it sends If-None-Match: "a1b2", the server compares and answers 304 with no body. In Network that row has status 304 and a tiny size — only headers travelled. If the Size column says (memory cache) or (disk cache) with status 200, no request went to the network at all.

Підводні камені. 304 із нульовим тілом — норма, а не обрив відповіді. Натомість 200 з тілом {"error": "..."} — справжній баг контракту, бо моніторинг і клієнт вважатимуть його успіхом. І не плутай 404 на неіснуючому маршруті SPA: там сервер має повертати index.html, а не помилку.

Pitfalls. A 304 with an empty body is normal, not a truncated response. A 200 carrying {"error": "..."}, however, is a real contract bug, because monitoring and the client will count it as success. And do not confuse a 404 on an unknown SPA route: there the server should return index.html, not an error.

Уточнююче питання. «Чим 301 відрізняється від 302?» — 301 постійний, браузер і пошуковики його кешують; 302 тимчасовий і перевіряється щоразу.

Follow-up question. "How is 301 different from 302?" 301 is permanent, and browsers and search engines cache it; 302 is temporary and gets checked every time.

Умовний запит і відповідь 304
A conditional request and a 304 response
GET /static/app.css HTTP/1.1
Host: shop.example.com
If-None-Match: "a1b2"

HTTP/1.1 304 Not Modified
ETag: "a1b2"
Cache-Control: no-cache
🔧 Розбір синтаксису: Перший блок — запит: метод і шлях, далі заголовки у форматі Назва: значення. If-None-Match несе ETag, який браузер запам'ятав минулого разу. Порожній рядок відділяє запит від відповіді. У відповіді рядок статусу 304 Not Modified, той самий ETag і жодного тіла; Cache-Control: no-cache каже «кешуй, але щоразу перепитуй сервер».
🔧 Syntax breakdown: The first block is the request: method and path, then headers in Name: value form. If-None-Match carries the ETag the browser remembered last time. The empty line separates request from response. The response has the 304 Not Modified status line, the same ETag and no body at all; Cache-Control: no-cache means "you may cache it, but check with the server every time".
💡 Простими словами: 304 — це сервер каже «у тебе вже є актуальна версія», тому тіла немає, і це правильно.
💡 In plain words: A 304 is the server saying "you already have the current version", so the missing body is correct.
🗣 Як читати вголос Я запам'ятовую коди групами: двохсоті — успіх, трьохсоті — редиректи й кеш, чотирьохсоті — клієнт щось зробив не так, п'ятисоті — впав сервер або проксі перед ним. Окремо завжди розводжу 401 і 403: перший про те, що тебе не впізнали, другий про те, що впізнали, але не пустили. 304 я бачу в Network постійно: браузер прислав ETag, сервер відповів, що нічого не змінилось, і тіла немає. Тому нульовий розмір у такому рядку для мене зовсім не баг. А от двохсотий статус з помилкою в тілі я якраз заведу як дефект контракту API.
🗣 How to say it out loud I remember codes by group: two hundreds are success, three hundreds are redirects and cache, four hundreds mean the client did something wrong, five hundreds mean the server or a proxy in front of it fell over. I always separate 401 and 403: the first is about not being recognised, the second is about being recognised and still refused. I see 304 in Network all the time: the browser sent an ETag, the server said nothing changed, and there is no body. So a zero size on that row is not a bug to me. A 200 with an error inside the body, on the other hand, I will absolutely log as a defect.
36 Що таке Same-Origin Policy і CORS і чому в Network 200, а в консолі «blocked by CORS policy»? What are the Same-Origin Policy and CORS, and why does Network show 200 while the console says "blocked by CORS policy"?
МережаNetwork Middle ▶

Що це. Same-Origin Policy (SOP) — правило браузера: JavaScript зі сторінки одного origin (схема + домен + порт) не може читати відповіді з іншого origin. CORS нічого не блокує — навпаки, це механізм, яким сервер послаблює SOP і дозволяє конкретним origin читати свої відповіді через заголовки Access-Control-Allow-Origin, -Methods, -Headers і -Credentials. Перевіряє їх браузер, а не сервер.

What it is. The Same-Origin Policy (SOP) is a browser rule: JavaScript on a page from one origin (scheme + host + port) cannot read responses from another origin. CORS blocks nothing — on the contrary, it is the mechanism a server uses to relax SOP and let specific origins read its responses, through the Access-Control-Allow-Origin, -Methods, -Headers and -Credentials headers. The browser checks them, not the server.

Навіщо. Для простого запиту (GET чи POST зі стандартними заголовками) браузер відправляє його одразу. Запит доходить до сервера, виконується, може змінити дані, і в Network ти бачиш статус 200. Лише потім браузер дивиться на відповідь: якщо в ній немає дозволу для твого origin, він ховає тіло від JS і пише в консоль «blocked by CORS policy». Тому curl і Postman ніколи не «ловлять» CORS — вони не браузери і SOP не застосовують.

Why it matters. A simple request (GET or POST with standard headers) is sent right away. It reaches the server, runs, may change data, and Network shows 200. Only afterwards does the browser inspect the response: if it lacks permission for your origin, the browser hides the body from JS and logs "blocked by CORS policy". That is why curl and Postman never "hit" CORS — they are not browsers and do not apply SOP.

Приклад. Фронт на https://shop.example.com викликає https://api.example.com/cart. Відповідь 200, але без Access-Control-Allow-Origin, і fetch падає з TypeError: Failed to fetch. Виправлення — на сервері або в dev-проксі: повертати Access-Control-Allow-Origin: https://shop.example.com і Vary: Origin.

Example. A frontend on https://shop.example.com calls https://api.example.com/cart. The response is 200 but carries no Access-Control-Allow-Origin, so fetch rejects with TypeError: Failed to fetch. The fix belongs on the server or in a dev proxy: return Access-Control-Allow-Origin: https://shop.example.com together with Vary: Origin.

Підводні камені. CORS — не захист від атакувальника: запит уже виконано, а від CSRF рятують токени і SameSite-кукі. Access-Control-Allow-Origin: * не працює разом із Allow-Credentials: true. Плагін «Disable CORS» у браузері ховає баг, а не лікує його. І пам'ятай: відповідь 500 без CORS-заголовків теж виглядає в консолі як CORS-помилка.

Pitfalls. CORS is not protection against an attacker: the request has already run, and CSRF is stopped by tokens and SameSite cookies. Access-Control-Allow-Origin: * does not work together with Allow-Credentials: true. A "Disable CORS" browser extension hides the bug instead of fixing it. And remember that a 500 without CORS headers also shows up in the console as a CORS error.

Уточнююче питання. «Що тоді з запитами, які змінюють дані PUT чи з JSON?» — для них браузер спершу робить preflight, і там основний запит без дозволу не піде взагалі.

Follow-up question. "What about requests that change data, like PUT or JSON bodies?" For those the browser sends a preflight first, and without permission the real request is never sent at all.

Відповідь, яку браузер пропустить до JS
A response the browser will hand to JS
# Request from https://shop.example.com
GET /cart HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com
Cookie: session=3f9c...

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
🔧 Розбір синтаксису: Рядок з # — коментар. Origin браузер додає сам, його не підробиш з JS. У відповіді Access-Control-Allow-Origin має точно збігтися з Origin (або бути * для запитів без кук), Allow-Credentials: true дозволяє читати відповідь на запит із куками, Vary: Origin каже кешам зберігати окрему копію для кожного origin. Прибери перший із цих заголовків — і статус лишиться 200, але JS отримає помилку.
🔧 Syntax breakdown: The line starting with # is a comment. The browser adds Origin itself, and JS cannot forge it. In the response, Access-Control-Allow-Origin must match the Origin exactly (or be * for requests without cookies), Allow-Credentials: true allows reading a response to a request sent with cookies, and Vary: Origin tells caches to keep a separate copy per origin. Remove the first of those headers and the status stays 200, but JS gets an error.
💡 Простими словами: Запит дійшов і виконався; браузер лише не віддав відповідь скрипту. Лікують це на сервері, а не в браузері.
💡 In plain words: The request arrived and ran; the browser only refused to hand the response to the script. The fix lives on the server, not in the browser.
🗣 Як читати вголос Тут важливо не переплутати ролі. Обмеження ставить сам браузер — це Same-Origin Policy, а CORS навпаки дає серверу спосіб сказати: цьому origin я дозволяю читати мої відповіді. Якщо запит простий, він реально долітає до бекенду й виконується, тому в Network я бачу 200. Браузер просто не віддає тіло скрипту, бо в відповіді немає потрібного Allow-Origin. Звідси ж пояснення, чому в Postman усе працює: це не браузер. У баг-репорті я пишу, якого заголовка бракує, і що виправляти треба на сервері. А ще нагадую команді, що CORS не захищає від CSRF.
🗣 How to say it out loud The key is not to mix up the roles. The restriction comes from the browser itself, the Same-Origin Policy, and CORS is the opposite: a way for the server to say this origin may read my responses. A simple request really does reach the backend and run, which is why I see 200 in Network. The browser just will not give the body to the script, because the response has no matching Allow-Origin. That also explains why everything works in Postman: it is not a browser. In the report I name the missing header and say the fix belongs on the server. And I remind the team that CORS is no defence against CSRF.
37 Що таке preflight-запит, коли браузер його шле і як його перевірити через curl? What is a preflight request, when does the browser send it, and how do you check it with curl?
МережаNetwork Middle ▶

Що це. Preflight — це службовий запит методом OPTIONS, який браузер сам шле перед «непростим» крос-доменним запитом, щоб спитати дозволу заздалегідь. У ньому є Origin, Access-Control-Request-Method і, за потреби, Access-Control-Request-Headers. Сервер відповідає переліком дозволеного, і лише якщо відповідь підходить, браузер відправляє основний запит.

What it is. A preflight is a service request with the OPTIONS method that the browser sends on its own before a "non-simple" cross-origin request, to ask for permission in advance. It carries Origin, Access-Control-Request-Method and, when needed, Access-Control-Request-Headers. The server answers with what it allows, and only if that answer fits does the browser send the real request.

Коли. Запит непростий, якщо метод не GET, HEAD чи POST (наприклад PUT, PATCH, DELETE), якщо є кастомні заголовки на кшталт Authorization чи X-Request-Id, або якщо Content-Type не з трійки text/plain, multipart/form-data, application/x-www-form-urlencoded. Тобто звичайний POST з application/json уже потребує preflight.

When. A request is non-simple if its method is not GET, HEAD or POST (PUT, PATCH, DELETE, say), if it has custom headers such as Authorization or X-Request-Id, or if its Content-Type is outside the trio text/plain, multipart/form-data, application/x-www-form-urlencoded. So an ordinary POST with application/json already needs a preflight.

Приклад. У Network з фільтром «All» видно пару рядків: OPTIONS /orders/42 і за ним PUT /orders/42. Якщо preflight отримав 404, 401 або відповідь без Access-Control-Allow-*, другого рядка не буде зовсім, а в консолі з'явиться «Response to preflight request doesn't pass access control check». Відтворити поведінку сервера можна через curl, підставивши Origin — і дозволений, і чужий.

Example. With the "All" filter, Network shows a pair of rows: OPTIONS /orders/42 followed by PUT /orders/42. If the preflight got a 404, a 401 or a response without Access-Control-Allow-*, the second row never appears, and the console says "Response to preflight request doesn't pass access control check". You can reproduce the server's behaviour with curl by sending both an allowed and a foreign Origin.

Підводні камені. Часто бекенд вимагає токен і на OPTIONS, а браузер у preflight ніколи не шле ні Authorization, ні кук — звідси 401 на preflight. Access-Control-Max-Age кешує дозвіл, тож після зміни конфігурації браузер може ще якийсь час використовувати стару відповідь. І curl доводить лише, що відповідає сервер; сам браузер він не імітує.

Pitfalls. Backends often demand a token on OPTIONS too, yet the browser never sends Authorization or cookies with a preflight — hence a 401 on the preflight. Access-Control-Max-Age caches the permission, so after a config change the browser may keep using the old answer for a while. And curl only proves what the server returns; it does not imitate the browser.

Уточнююче питання. «Чи може preflight змінити дані?» — ні, OPTIONS-обробник має лише повідомити дозволи, основний запит до сервера ще не дійшов.

Follow-up question. "Can a preflight change data?" No, the OPTIONS handler should only report permissions; the real request has not reached the server yet.

Перевірка preflight з чужого origin
Checking the preflight from a foreign origin
curl -i -X OPTIONS 'https://api.example.com/orders/42' \
  -H 'Origin: https://evil.test' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: content-type,authorization'

# Expected for https://evil.test: no Access-Control-Allow-Origin at all
HTTP/2 204
vary: Origin

# Expected for Origin: https://shop.example.com
HTTP/2 204
access-control-allow-origin: https://shop.example.com
access-control-allow-methods: GET, POST, PUT, DELETE
access-control-allow-headers: content-type, authorization
access-control-allow-credentials: true
access-control-max-age: 600
🔧 Розбір синтаксису: -X OPTIONS імітує метод preflight, -H 'Origin: ...' підставляє origin сторінки, Access-Control-Request-Method і -Headers описують майбутній основний запит. Для чужого origin правильна відповідь — без access-control-allow-origin: браузер тоді не пустить PUT. Для свого — дозволені методи й заголовки, allow-credentials для кук і max-age у секундах, на скільки браузер може закешувати дозвіл.
🔧 Syntax breakdown: -X OPTIONS imitates the preflight method, -H 'Origin: ...' sets the page origin, and Access-Control-Request-Method and -Headers describe the upcoming real request. For a foreign origin the correct answer has no access-control-allow-origin, so the browser will not send the PUT. For your own origin it lists allowed methods and headers, allow-credentials for cookies and max-age in seconds for how long the browser may cache the permission.
💡 Простими словами: Preflight — це «можна?» перед основним запитом. Немає дозволу — основного запиту взагалі не буде.
💡 In plain words: A preflight is a "may I?" before the real request. No permission means no real request at all.
🗣 Як читати вголос Preflight — це запит методом OPTIONS, який браузер робить сам, коли основний запит не простий: PUT чи DELETE, JSON у тілі або свій заголовок на зразок Authorization. Браузер ніби питає сервер: цьому origin можна зробити PUT з такими заголовками? Якщо сервер не відповів потрібними Allow-заголовками, основний запит просто не відправляється, і в Network я бачу лише OPTIONS. Щоб перевірити налаштування, я шлю curl з методом OPTIONS і двома різними Origin — нашим і чужим. Часта знахідка — бекенд вимагає токен на OPTIONS, хоча браузер його туди не кладе.
🗣 How to say it out loud A preflight is an OPTIONS request the browser makes by itself when the real request is not simple: PUT or DELETE, JSON in the body, or a custom header like Authorization. The browser is basically asking the server: may this origin do a PUT with these headers? If the server does not reply with the right Allow headers, the real request is simply never sent, and in Network I see only the OPTIONS. To check the configuration I fire curl with the OPTIONS method and two different Origins, ours and a foreign one. A common find is a backend that demands a token on OPTIONS, even though the browser never puts one there.
38 Після логіну користувача одразу викидає на форму входу. Що дивитись у Network першим? Right after logging in, the user is thrown back to the login form. What do you check first in Network?
МережаNetwork Middle ▶

Що це. Перше, що варто перевірити, — заголовок Set-Cookie у відповіді на запит логіну. Статус 200 на /login нічого не доводить: сесія може не дійти до наступного запиту. Типова картина — сервер кукі видав, браузер її не зберіг або не відправив, наступний /api/me отримав 401, і фронт зробив редирект на форму входу.

What it is. The first thing to check is the Set-Cookie header in the response to the login request. A 200 on /login proves nothing: the session may never reach the next request. The typical picture is that the server issued the cookie, the browser did not store or did not send it, the following /api/me got a 401, and the frontend redirected to the login form.

Навіщо саме так. Увімкни Preserve log, інакше редирект зітре історію запитів. Далі йди ланцюжком: відповідь логіну → чи є Set-Cookie → чи немає біля нього жовтого трикутника з поясненням, чому кукі заблокована → чи є Cookie у заголовках наступного запиту → що той запит відповів. У вкладці Cookies конкретного запиту Chrome показує і відфільтровані куки з причиною.

Why in this order. Turn on Preserve log, otherwise the redirect wipes the request history. Then follow the chain: the login response → is there a Set-Cookie → is there a yellow triangle next to it explaining why the cookie was blocked → does the next request carry a Cookie header → what did that request return. The Cookies tab of a request in Chrome also lists filtered-out cookies with a reason.

Приклад. На стейджі http://stage.shop.local логін повертає Set-Cookie: sid=...; Secure; SameSite=None. Кука з прапорцем Secure через HTTP не зберігається, тож /api/me іде без неї, отримує 401, і користувача викидає. Інші часті причини: Domain=api.shop.com, коли фронт живе на shop.com; Path=/auth; SameSite=Strict після редиректу з SSO; годинник сервера, що робить Expires вже минулим.

Example. On staging at http://stage.shop.local the login returns Set-Cookie: sid=...; Secure; SameSite=None. A Secure cookie is not stored over plain HTTP, so /api/me goes out without it, gets a 401 and the user is kicked out. Other frequent causes: Domain=api.shop.com while the frontend lives on shop.com; Path=/auth; SameSite=Strict after a redirect from SSO; a server clock that makes Expires already past.

Підводні камені. Якщо токен лежить не в куці, а в localStorage, дивись на заголовок Authorization у наступному запиті і на Application → Local Storage. Крос-доменний fetch без credentials: 'include' не шле кук навіть при правильних прапорцях. А сторонні куки можуть бути заблоковані налаштуваннями браузера.

Pitfalls. If the token lives in localStorage rather than a cookie, look at the Authorization header of the next request and at Application → Local Storage. A cross-origin fetch without credentials: 'include' sends no cookies even when the flags are right. And third-party cookies may be blocked by browser settings.

Уточнююче питання. «Як це закріпити автотестом?» — перехопити відповідь логіну й перевірити атрибути куки, а потім переконатися, що наступна захищена сторінка відкривається без редиректу.

Follow-up question. "How would you lock this in with a test?" Intercept the login response and assert the cookie attributes, then make sure the next protected page opens without a redirect.

Ланцюжок запитів у Network з Preserve log
The request chain in Network with Preserve log
POST /api/login            200
set-cookie: sid=7c1e...; Path=/; Secure; HttpOnly; SameSite=None
# ⚠ жовтий трикутник: кука з Secure не збережена через http://

GET /api/me                401
# у Request Headers немає рядка cookie: sid=...

GET /login                 200   # фронт отримав 401 і зробив редирект
POST /api/login            200
set-cookie: sid=7c1e...; Path=/; Secure; HttpOnly; SameSite=None
# ⚠ yellow triangle: a Secure cookie was not stored over http://

GET /api/me                401
# Request Headers have no cookie: sid=... line

GET /login                 200   # the frontend got 401 and redirected
🔧 Розбір синтаксису: Кожен блок — рядок Network: метод, шлях і статус, під ним важливий заголовок. set-cookie містить значення і атрибути через крапку з комою: Path, Secure (лише HTTPS), HttpOnly (недоступна з JS), SameSite. Рядки з # — те, що ти побачиш у DevTools. Ланцюжок читається згори вниз: кука не збереглась → наступний запит без неї → 401 → редирект.
🔧 Syntax breakdown: Each block is a Network row: method, path and status, with the relevant header below. set-cookie holds the value and attributes separated by semicolons: Path, Secure (HTTPS only), HttpOnly (hidden from JS), SameSite. Lines starting with # describe what you will see in DevTools. Read the chain top to bottom: cookie not stored → next request without it → 401 → redirect.
💡 Простими словами: Не вір 200 на логіні — простеж, чи сесійна кука дожила до наступного запиту.
💡 In plain words: Do not trust a 200 on login — follow the session cookie and see whether it survives to the next request.
🗣 Як читати вголос Перше, що я роблю, — вмикаю Preserve log, бо інакше редирект зітре всю історію. Потім відкриваю відповідь логіну і шукаю Set-Cookie. Якщо кука є, дивлюсь, чи не стоїть біля неї жовтий трикутник: Chrome прямо пише, що її заблоковано, наприклад через Secure на http-стейджі чи чужий Domain. Далі перевіряю, чи наступний запит справді несе цю куку, і що він відповів. Зазвичай там 401, після якого фронт і кидає на логін. Статус 200 на самому логіні я не вважаю доказом, що все добре — важливо, чи сесія доїхала до наступного запиту.
🗣 How to say it out loud The first thing I do is turn on Preserve log, because otherwise the redirect wipes the whole history. Then I open the login response and look for Set-Cookie. If the cookie is there, I check for a yellow triangle next to it: Chrome states plainly that it was blocked, say because of Secure on an http staging site or a foreign Domain. Next I check whether the following request actually carries that cookie and what it returned. Usually it is a 401, after which the frontend throws the user back to login. A 200 on the login itself is not proof to me that all is well; what matters is whether the session travelled on.
39 Після релізу частина користувачів лишається на старій версії SPA. Що перевірити в заголовках першим? After a release some users stay on the old version of the SPA. Which headers do you check first?
МережаNetwork Senior ▶

Що це. Першим перевір Cache-Control на index.html. У SPA HTML — єдина точка входу, яка посилається на бандли з хешем в імені, наприклад app.3f9c1e.js. Якщо HTML закешовано надовго, браузер чи CDN віддає старий документ, а той тягне старі бандли — і користувач не бачить релізу, хоча деплой пройшов.

What it is. Check Cache-Control on index.html first. In an SPA the HTML is the single entry point that references bundles with a hash in their name, such as app.3f9c1e.js. If the HTML is cached for a long time, the browser or CDN serves the old document, which pulls the old bundles — and the user never sees the release even though the deploy succeeded.

Правильна схема. Для index.html: Cache-Control: no-cache (кешувати можна, але щоразу перевалідовувати через ETag) або max-age=0, must-revalidate. Для хешованих бандлів: public, max-age=31536000, immutable — хеш змінюється з кожною зміною вмісту, тож довгий кеш безпечний. Помилка в будь-який бік шкодить: довгий кеш на HTML ламає оновлення, короткий на бандлах марнує трафік і сповільнює кожен візит.

The right scheme. For index.html: Cache-Control: no-cache (it may be cached but must be revalidated through the ETag every time) or max-age=0, must-revalidate. For hashed bundles: public, max-age=31536000, immutable — the hash changes whenever the content changes, so a long cache is safe. Getting it wrong either way hurts: a long cache on HTML breaks updates, a short one on bundles wastes traffic and slows every visit.

Приклад. Після релізу скаржиться лише частина людей. Через curl -I бачиш у HTML cache-control: public, max-age=86400 і age: 51200 — CDN тримає документ добу, а цей вузол віддає копію 14-годинної давнини. Заголовки x-cache: HIT чи cf-cache-status: HIT підтверджують, що відповідь прийшла з кешу CDN. Короткостроково — інвалідація CDN, системно — зміна політики кешування HTML у конфігурації сервера.

Example. After the release only some people complain. With curl -I you see cache-control: public, max-age=86400 and age: 51200 on the HTML — the CDN keeps the document for a day, and this edge node serves a copy fourteen hours old. Headers like x-cache: HIT or cf-cache-status: HIT confirm the response came from the CDN cache. Short term the answer is a CDN purge; the lasting fix is changing the HTML caching policy in the server config.

Компроміси й підводні камені. Частковість симптому — ознака саме кешу: різні вузли CDN, різні браузери з різною давниною копії. Окремий шар — service worker, який віддає HTML зі свого кешу незалежно від заголовків. Ще одна пастка: якщо стара версія видаляє старі чанки з сервера, користувачі з закешованим HTML отримають ChunkLoadError, тому попередню збірку варто тримати на сервері хоча б кілька днів.

Trade-offs and pitfalls. A symptom that hits only some users is itself a cache signature: different CDN nodes, different browsers holding copies of different age. A separate layer is the service worker, which serves HTML from its own cache regardless of headers. Another trap: if the new release deletes the old chunks from the server, users with cached HTML get a ChunkLoadError, so keep the previous build on the server for at least a few days.

Уточнююче питання. «Як це закріпити?» — додати в смоук після деплою перевірку заголовків HTML і бандла через curl, щоб регресія конфігурації не пройшла непомітно.

Follow-up question. "How do you lock it in?" Add a post-deploy smoke step that checks the headers of the HTML and one bundle with curl, so a config regression cannot slip through unnoticed.

Заголовки HTML і бандла після деплою
HTML and bundle headers after a deploy
curl -sI 'https://shop.example.com/' | grep -iE 'cache-control|etag|age|x-cache|cf-cache-status'
# wanted:  cache-control: no-cache
#          etag: "5d8f-1f3a"
# broken:  cache-control: public, max-age=86400
#          age: 51200
#          cf-cache-status: HIT

curl -sI 'https://shop.example.com/assets/app.3f9c1e.js' | grep -i cache-control
# wanted:  cache-control: public, max-age=31536000, immutable
🔧 Розбір синтаксису: -s — тихий режим, -I — HEAD-запит, лише заголовки. Вивід передається через | у grep -iE: -i ігнорує регістр, -E вмикає регулярний вираз, де | означає «або». Рядки з # — очікуваний і зламаний варіанти. age — скільки секунд копія лежить у кеші CDN, immutable каже браузеру не перевалідовувати файл навіть при оновленні сторінки.
🔧 Syntax breakdown: -s is silent mode, -I sends a HEAD request for headers only. The output is piped with | into grep -iE: -i ignores case, -E enables an extended regex where | means "or". Lines starting with # show the wanted and the broken variant. age is how many seconds the copy has sat in the CDN cache, and immutable tells the browser not to revalidate the file even on reload.
💡 Простими словами: HTML має перевірятися щоразу, хешовані файли можна кешувати на рік. Навпаки — і реліз «не доїжджає».
💡 In plain words: The HTML must be revalidated every time; hashed files can be cached for a year. Swap them and the release "never arrives".
🗣 Як читати вголос Коли реліз бачать не всі, я одразу підозрюю кеш, а не деплой. Першим дивлюсь на Cache-Control самого index.html, бо це точка входу, яка вирішує, які бандли завантажити. Там має бути no-cache або max-age нуль з ревалідацією, а от файли з хешем в імені можна кешувати на рік з immutable. Через curl з великою I я перевіряю ще Age і статус кешу CDN — HIT з великим Age одразу пояснює, чому частина людей на старій версії. Окремо тримаю в голові service worker і старі чанки на сервері. А закріплюю все це перевіркою заголовків у смоуку після деплою.
🗣 How to say it out loud When only some people see a release, I suspect the cache before the deploy. First I look at Cache-Control on index.html itself, because it is the entry point that decides which bundles get loaded. It should be no-cache, or max-age zero with revalidation, whereas files with a hash in the name can be cached for a year with immutable. With curl and capital I, I also check Age and the CDN cache status — a HIT with a large Age explains immediately why part of the audience is on the old version. I keep the service worker and old chunks on the server in mind too. And I lock it in with a header check in the post-deploy smoke.
40 Користувач бачить стару сторінку після релізу — у якому порядку локалізувати причину? A user sees the old page after a release — in what order do you narrow down the cause?
МережаNetwork Senior ▶

Що це. Це задача на відсікання шарів: сервер, CDN, HTTP-кеш браузера і service worker. Живий воркер зі старим кешем виглядає точно як недокачений деплой, і відрізнити їх можна лише за джерелом відповіді. Тому порядок важливіший за окремі прийоми.

What it is. This is a task of peeling off layers: server, CDN, the browser's HTTP cache and the service worker. A live worker with a stale cache looks exactly like a half-finished deploy, and only the source of the response tells them apart. So the order matters more than any single trick.

Порядок. 1) Звір, що віддає сервер, з тим, що бачить користувач: curl на сторінку або /version.json, номер збірки в футері. 2) У Network знайди відповіді, у яких Size показує (ServiceWorker), а не мережу чи disk cache. 3) Application → Service Workers: стан, дата і скрипт воркера, чи немає нового у статусі waiting. 4) Unregister, Clear site data у Application → Storage, перезавантаження. 5) Якщо після цього свіжо, причина — стратегія кешування воркера, а не реліз.

The order. 1) Compare what the server returns with what the user sees: curl the page or a /version.json, the build number in the footer. 2) In Network, find responses whose Size shows (ServiceWorker) rather than the network or disk cache. 3) Application → Service Workers: the worker's state, date and script, and whether a new one sits in waiting. 4) Unregister, Clear site data under Application → Storage, reload. 5) If it is fresh after that, the cause is the worker's caching strategy, not the release.

Приклад. Сервер уже віддає збірку 2.14, у користувача 2.13. У Network index.html позначений (ServiceWorker), а в Application видно активний sw.js і новий у стані «waiting to activate». Воркер застосовує стратегію cache-first для навігації і не має skipWaiting чи підказки «оновити». Висновок для розробників: змінити стратегію для HTML на network-first або показувати банер оновлення.

Example. The server already serves build 2.14, the user has 2.13. In Network index.html is marked (ServiceWorker), and Application shows an active sw.js plus a new one "waiting to activate". The worker uses cache-first for navigations and has neither skipWaiting nor an "update available" prompt. The conclusion for developers: switch the HTML strategy to network-first or show an update banner.

Компроміси й підводні камені. Жорстке перезавантаження (Ctrl+Shift+R) обходить воркер, тож «у мене все свіже» з ним нічого не доводить. Інкогніто прибирає і кеш, і воркер одночасно — зручно для перевірки, але не дає зрозуміти, котрий шар винен. Якщо ж свіжіше не стало і після очищення, повертайся до заголовків і CDN. Ще окремий симптом — ChunkLoadError у консолі, коли старий HTML просить видалені чанки.

Trade-offs and pitfalls. A hard reload (Ctrl+Shift+R) bypasses the worker, so "it is fresh for me" after one proves nothing. Incognito removes both the cache and the worker at once — handy for a check, but it hides which layer was to blame. If nothing changes even after clearing, go back to headers and the CDN. A separate symptom is a ChunkLoadError in the console, when old HTML asks for chunks that were deleted.

Уточнююче питання. «Що попросити в користувача, якщо доступу до його браузера немає?» — версію з футера або з консолі, скріншот Network з колонкою Size і HAR-файл.

Follow-up question. "What do you ask a user for when you cannot touch their browser?" The version from the footer or the console, a Network screenshot with the Size column, and a HAR file.

Сніпет для Console: версія і стан воркера
Console snippet: version and worker state
// 1. Що віддає сервер прямо зараз, в обхід кешу
const fresh = await fetch('/version.json', { cache: 'no-store' }).then(r => r.json());

// 2. Які воркери зареєстровані і в якому стані
const regs = await navigator.serviceWorker.getRegistrations();
console.table(regs.map(r => ({
  scope: r.scope,
  active: r.active?.scriptURL,
  waiting: Boolean(r.waiting),   // true — нова версія чекає, поки закриють вкладки
})));
console.log('сервер:', fresh.build, 'сторінка:', window.APP_BUILD);
// 1. What the server returns right now, bypassing the cache
const fresh = await fetch('/version.json', { cache: 'no-store' }).then(r => r.json());

// 2. Which workers are registered and in what state
const regs = await navigator.serviceWorker.getRegistrations();
console.table(regs.map(r => ({
  scope: r.scope,
  active: r.active?.scriptURL,
  waiting: Boolean(r.waiting),   // true — a new version waits for tabs to close
})));
console.log('server:', fresh.build, 'page:', window.APP_BUILD);
🔧 Розбір синтаксису: У консолі DevTools дозволений await на верхньому рівні. fetch з { cache: 'no-store' } іде повз HTTP-кеш браузера (воркер при цьому може перехопити запит, якщо так запрограмований). navigator.serviceWorker.getRegistrations() повертає всі реєстрації для origin; r.active і r.waiting — активний і очікуючий воркери, ?. не падає, якщо їх немає. console.table малює результат таблицею. /version.json і window.APP_BUILD — умовні назви, у твоєму проєкті вони можуть бути інші.
🔧 Syntax breakdown: The DevTools console allows top-level await. fetch with { cache: 'no-store' } skips the browser's HTTP cache (a worker can still intercept it if it is written to). navigator.serviceWorker.getRegistrations() returns every registration for the origin; r.active and r.waiting are the active and the waiting workers, and ?. does not throw when they are missing. console.table prints the result as a table. /version.json and window.APP_BUILD are example names; your project may use different ones.
💡 Простими словами: Спершу доведи, звідки прийшла стара відповідь, і лише тоді звинувачуй деплой.
💡 In plain words: First prove where the stale response came from, and only then blame the deploy.
🗣 Як читати вголос Я йду шарами і не стрибаю одразу до висновку. Спершу звіряю версію, яку реально віддає сервер, з тим, що бачить користувач. Якщо вони різні, дивлюсь у Network, звідки прийшов документ, і дуже часто там написано ServiceWorker. Тоді відкриваю Application, дивлюсь стан воркера і чи не висить новий у статусі waiting. Після unregister і очищення сховища перезавантажую: якщо все свіже, то проблема в стратегії кешування воркера, а не в релізі. Жорстке перезавантаження як доказ я не приймаю, бо воно обходить воркер і маскує причину.
🗣 How to say it out loud I go layer by layer and do not jump to conclusions. First I compare the version the server actually returns with what the user sees. If they differ, I look in Network at where the document came from, and very often it says ServiceWorker. Then I open Application to check the worker state and whether a new one is stuck in waiting. After unregistering and clearing storage I reload: if everything is fresh, the problem is the worker's caching strategy, not the release. I never accept a hard reload as evidence, because it bypasses the worker and hides the cause.
▶ 6 9
41 Що робить атрибут required і як працює вбудована валідація форми? What does the required attribute do, and how does built-in form validation work?
ФормиForms Junior ▶

Що це. required — булевий атрибут для <input>, <select> і <textarea>. Коли користувач тисне Submit, браузер запускає constraint validation: якщо обов'язкове поле порожнє, відправка скасовується, фокус стрибає на перше невалідне поле, а поруч з'являється нативна підказка на кшталт «Заповніть це поле». Жодного рядка JavaScript для цього не треба.

What it is. required is a boolean attribute for <input>, <select> and <textarea>. When the user presses Submit, the browser runs constraint validation: if a mandatory field is empty, the submission is cancelled, focus jumps to the first invalid field and a native bubble such as "Please fill out this field" appears. No JavaScript is needed for any of it.

Навіщо тестеру. Стан валідації можна прочитати напряму: у кожного поля є об'єкт validity з прапорцями valueMissing, typeMismatch, patternMismatch тощо, рядок validationMessage і метод checkValidity(). CSS-псевдоклас :invalid дає змогу знайти всі невалідні поля одним селектором. Це стабільніше, ніж перевіряти текст спливаючої підказки, бо саму підказку малює браузер поза DOM і її не видно в Elements.

Why it matters. The validation state can be read directly: every field has a validity object with flags like valueMissing, typeMismatch and patternMismatch, a validationMessage string and a checkValidity() method. The :invalid CSS pseudo-class finds all invalid fields with one selector. That is more stable than asserting on the bubble text, because the browser draws the bubble outside the DOM and Elements never shows it.

Приклад. У формі реєстрації поле Email має required. Автотест тисне «Зареєструватись» із порожнім полем і перевіряє дві речі: запит на /api/register не пішов, а validity.valueMissing дорівнює true. Текст validationMessage залежить від мови браузера, тому його краще не хардкодити.

Example. On a sign-up form the Email field carries required. The automated test clicks "Sign up" with the field empty and checks two things: no request reached /api/register, and validity.valueMissing is true. The validationMessage text depends on the browser language, so hardcoding it is a bad idea.

Підводні камені. Нативна валідація живе тільки в браузері: будь-хто знімає атрибут у DevTools або шле запит через curl, тож бекенд зобов'язаний перевіряти те саме. Пробіли теж вважаються значенням — поле з одним пробілом проходить required. А атрибут novalidate на <form> або formnovalidate на кнопці вимикає всю цю поведінку.

Pitfalls. Native validation lives in the browser only: anyone can strip the attribute in DevTools or send the request with curl, so the backend must check the same rule. Whitespace counts as a value, so a field holding a single space passes required. And novalidate on the <form> or formnovalidate on a button switches the whole mechanism off.

Уточнююче питання. «Чи спрацює required, якщо форму відправити через form.submit()?» Ні: цей метод обходить валідацію, на відміну від form.requestSubmit().

Follow-up question. "Does required fire when the form is sent with form.submit()?" No: that method skips validation, unlike form.requestSubmit().

Розмітка і перевірка в Playwright
Markup and a Playwright check
// <input id="email" type="email" required>
await page.getByRole('button', { name: 'Зареєструватись' }).click();

const email = page.locator('#email');
// поле порожнє — браузер виставив прапорець valueMissing
expect(await email.evaluate(el => el.validity.valueMissing)).toBe(true);
// текст підказки залежить від мови браузера, тому лише перевіряємо, що він є
expect(await email.evaluate(el => el.validationMessage)).not.toBe('');
// <input id="email" type="email" required>
await page.getByRole('button', { name: 'Sign up' }).click();

const email = page.locator('#email');
// the field is empty, so the browser set the valueMissing flag
expect(await email.evaluate(el => el.validity.valueMissing)).toBe(true);
// the hint text depends on the browser language, so only check it exists
expect(await email.evaluate(el => el.validationMessage)).not.toBe('');
🔧 Розбір синтаксису: locator.evaluate(fn) виконує функцію в браузері, передаючи їй DOM-елемент el, і повертає результат у тест. el => el.validity.valueMissing — стрілкова функція, що читає прапорець ValidityState. .not.toBe('') — інверсія матчера: значення не має дорівнювати порожньому рядку.
🔧 Syntax breakdown: locator.evaluate(fn) runs the function inside the browser, hands it the DOM element el and returns the result to the test. el => el.validity.valueMissing is an arrow function reading a ValidityState flag. .not.toBe('') inverts the matcher: the value must not equal an empty string.
💡 Простими словами: required зупиняє сабміт у браузері, але не захищає бекенд. Перевіряй стан через validity, а не текст підказки.
💡 In plain words: required stops the submit in the browser but protects nothing on the backend. Check the state through validity, not the bubble text.
🗣 Як читати вголос Якщо коротко, required каже браузеру не відпускати форму, поки поле порожнє. Браузер сам зупиняє сабміт, ставить фокус на проблемне поле і показує свою підказку, і все це без JavaScript. В автотестах я не чіпляюсь за текст цієї підказки, бо він різний у різних мовах і браузерах, а читаю validity.valueMissing і дивлюсь, що запит так і не пішов у мережу. І обов'язково окремо ганяю той самий кейс проти API, бо атрибут у DevTools видаляється за секунду, а пробіл, до речі, required теж пропускає, тож і цей кейс я додаю в список.
🗣 How to say it out loud Put simply, required tells the browser not to let the form go while the field is empty. The browser stops the submit on its own, moves focus to the problem field and shows its own hint, all without any JavaScript. In automation I never pin the hint text, since it changes with the language and the browser; I read validity.valueMissing and confirm that no request went out on the network. And I always run the same case against the API separately, because the attribute can be deleted in DevTools in a second, and a single space slips past required anyway.
42 disabled чи readonly — що не потрапить у сабміт? disabled or readonly — which one is left out of the submit?
ФормиForms Middle ▶

Що це. Обидва атрибути роблять поле незмінним для користувача, але поводяться по-різному. Поле з disabled не отримує фокус, не бере участі в валідації і не потрапляє в дані форми взагалі — ні ключа, ні значення. Поле з readonly можна сфокусувати й виділити текст, воно пропускає валідацію обмежень, але відправляється разом з формою.

What it is. Both attributes stop the user from changing a field, yet they behave differently. A disabled field cannot take focus, is excluded from validation and is left out of the form data entirely, no key and no value. A readonly field can be focused and its text selected, it is skipped by constraint validation, but it is submitted with the form.

Навіщо. Розробник часто «сірить» поле, щоб користувач не міняв, скажімо, email в профілі, і обирає disabled заради вигляду. Наслідок: ключ email мовчки зникає з payload, і бекенд або перезаписує значення на порожнє, або відповідає 400. Зовні все виглядає однаково, тому доказ шукаєш лише в Network або через FormData.

Why it matters. Developers often grey out a field so the user cannot change, say, the email in a profile, and pick disabled for the looks. The result: the email key silently disappears from the payload, and the backend either overwrites the value with an empty one or answers 400. On screen nothing differs, so the evidence is only in Network or in FormData.

Приклад. Форма замовлення має поле «Промокод» у стані readonly і поле «Ціна» в стані disabled. У консолі new FormData(form) покаже тільки промокод. Якщо бекенд чекає ціну, замовлення впаде, і причина — атрибут, а не логіка сервера.

Example. An order form has a "Promo code" field set to readonly and a "Price" field set to disabled. In the console new FormData(form) lists only the promo code. If the backend expects the price, the order fails, and the cause is an attribute, not server logic.

Підводні камені. disabled на <fieldset> вимикає всі вкладені поля одразу, і всі вони випадуть із сабміту. readonly працює лише для текстових полів: на <select>, чекбоксах і радіокнопках його ігнорують, тож такий «замок» не тримає. Ще нюанс для доступності: disabled-кнопка недосяжна з Tab, і скрінрідер може її пропустити.

Pitfalls. disabled on a <fieldset> switches off every nested field at once, and all of them drop out of the submit. readonly only applies to text-like fields: <select>, checkboxes and radio buttons ignore it, so that lock does not hold. One more accessibility detail: a disabled button is unreachable with Tab, and a screen reader may skip it.

Уточнююче питання. «Як залишити значення в запиті, але заборонити редагування?» Використай readonly або дублюй значення в <input type="hidden">, і все одно перевір це правило на бекенді.

Follow-up question. "How do you keep the value in the request but block editing?" Use readonly or mirror the value in an <input type="hidden">, and still enforce the rule on the backend.

Що реально піде в запит — перевірка в консолі
What really goes into the request — a console check
<form id="order">
  <input name="promo" value="SPRING10" readonly>
  <input name="price" value="499" disabled>
</form>
<script>
  // у консолі DevTools: які пари ключ-значення реально відправляться
  console.table([...new FormData(document.forms.order)]);
  // результат: лише ["promo", "SPRING10"], ключа price немає
</script>
<form id="order">
  <input name="promo" value="SPRING10" readonly>
  <input name="price" value="499" disabled>
</form>
<script>
  // in the DevTools console: which key-value pairs will really be sent
  console.table([...new FormData(document.forms.order)]);
  // result: only ["promo", "SPRING10"], there is no price key
</script>
🔧 Розбір синтаксису: new FormData(form) збирає дані форми за тими самими правилами, що й браузер під час сабміту. Спред [...fd] перетворює ітератор на масив пар [name, value], а console.table виводить їх таблицею. document.forms.order — доступ до форми за її id через колекцію document.forms.
🔧 Syntax breakdown: new FormData(form) collects the form data by the same rules the browser uses on submit. The spread [...fd] turns the iterator into an array of [name, value] pairs, and console.table prints them as a table. document.forms.order reaches the form by its id through the document.forms collection.
💡 Простими словами: readonly — «дивись, але відправ», disabled — «ніби й немає». Сіре поле перевіряй у Network, а не очима.
💡 In plain words: readonly means look but still send; disabled means as if it were not there. Check a greyed field in Network, not by eye.
🗣 Як читати вголос Тут легко помилитися, бо обидва поля виглядають однаково сірими. Різниця в тому, що disabled браузер повністю викидає з даних форми, ключа навіть немає, а readonly просто не дає редагувати, але значення їде на сервер. Я одного разу ловив баг, коли email у профілі зробили disabled, і після збереження бекенд затирав його порожнім рядком. Тому на такі поля я завжди дивлюсь payload у Network або в консолі збираю FormData. Ну і пам'ятаю про fieldset з disabled, він вимикає одразу цілу групу полів, і всі вони разом зникають із запиту.
🗣 How to say it out loud This one is easy to get wrong, because both fields look equally grey. The difference is that the browser throws a disabled field out of the form data completely, the key is not even there, whereas readonly only blocks editing and the value still travels to the server. I once caught a bug where the profile email had been made disabled, and after saving, the backend wiped it with an empty string. So for fields like that I always read the payload in Network or build a FormData in the console. I also keep in mind a disabled fieldset, which turns off a whole group of fields at once.
43 Поле має pattern, але не має required — чи пройде порожнє значення? A field has pattern but no required — does an empty value pass?
ФормиForms Middle ▶

Що це. Так, порожнє значення проходить. Атрибут pattern перевіряє форму значення, а не його наявність: за специфікацією, якщо поле порожнє, перевірка шаблону просто не запускається. Обов'язковість дає лише required. Те саме стосується minlength і type="email": порожній email не є typeMismatch.

What it is. Yes, an empty value passes. The pattern attribute checks the shape of a value, not whether there is one: by the spec, when the field is empty the pattern check does not run at all. Only required makes a field mandatory. The same goes for minlength and type="email": an empty email is not a typeMismatch.

Навіщо. Це класичне джерело багів «необов'язкове поле з форматом». Розробник додає pattern="\d{5}" для поштового індексу й думає, що поле тепер захищене, а форма спокійно відправляє zip=. Якщо бекенд очікує рівно п'ять цифр, ти отримуєш 500 або запис з порожнім індексом у базі.

Why it matters. This is a classic source of "optional field with a format" bugs. A developer adds pattern="\d{5}" for a postcode and assumes the field is now protected, while the form happily sends zip=. If the backend expects exactly five digits, you get a 500 or a database row with an empty postcode.

Приклад. Для тест-дизайну поля з pattern і без required бери щонайменше чотири класи: порожнє (проходить), валідне 01001 (проходить), невалідне 1234 (patternMismatch), валідне з пробілом на початку (не проходить, бо пробіл — теж символ). Окремо перевір, що бекенд приймає порожнє так само, як фронт.

Example. For the test design of a field with pattern but no required, take at least four classes: empty (passes), valid 01001 (passes), invalid 1234 (patternMismatch), valid with a leading space (fails, because a space is a character too). Separately check that the backend treats empty the same way the front end does.

Підводні камені. Шаблон неявно прив'язаний до всього значення: браузер обгортає його як ^(?:pattern)$, тож \d{5} не пропустить 123456. З 2023 року браузери компілюють шаблон з прапорцем v, тому деякі символи в класах, як-от незаекранований дефіс у [a-z-], роблять регулярку невалідною, і тоді валідація за шаблоном мовчки не працює. Корисно дивитись у Console, чи немає там попередження про pattern.

Pitfalls. The pattern is implicitly anchored to the whole value: the browser wraps it as ^(?:pattern)$, so \d{5} rejects 123456. Since 2023 browsers compile the pattern with the v flag, so some characters inside classes, such as an unescaped hyphen in [a-z-], make the regex invalid, and then pattern validation silently does nothing. It pays to look in the Console for a warning about the pattern.

Уточнююче питання. «Як показати зрозуміле повідомлення замість стандартного?» Атрибутом title з описом формату або через setCustomValidity().

Follow-up question. "How do you show a clear message instead of the default one?" With a title attribute describing the format, or through setCustomValidity().

Порожнє проти невалідного значення
Empty versus invalid value
// <input id="zip" name="zip" pattern="\d{5}" title="5 цифр">
const zip = document.querySelector('#zip');

zip.value = '';
zip.checkValidity();          // true  — порожнє поле pattern не перевіряє
zip.value = '1234';
zip.validity.patternMismatch; // true  — формат не збігся
zip.value = '123456';
zip.checkValidity();          // false — шаблон прив'язаний до всього значення
// <input id="zip" name="zip" pattern="\d{5}" title="5 digits">
const zip = document.querySelector('#zip');

zip.value = '';
zip.checkValidity();          // true  — an empty field skips the pattern
zip.value = '1234';
zip.validity.patternMismatch; // true  — the format did not match
zip.value = '123456';
zip.checkValidity();          // false — the pattern is anchored to the whole value
🔧 Розбір синтаксису: \d{5} — рівно п'ять цифр; у HTML-атрибуті пишеш одну похилу риску, тут вона подвоєна лише всередині рядка коментаря. checkValidity() повертає true або false для всього поля, а validity.patternMismatch — конкретний прапорець причини. Присвоєння zip.value змінює значення так само, як введення з клавіатури, для цілей валідації.
🔧 Syntax breakdown: \d{5} means exactly five digits; in the HTML attribute you write a single backslash, it is doubled here only inside the comment string. checkValidity() returns true or false for the whole field, while validity.patternMismatch is the specific reason flag. Assigning zip.value changes the value the same way typing would, as far as validation goes.
💡 Простими словами: pattern відповідає на питання «яке значення», required — «чи воно є». Без required порожнє завжди проходить.
💡 In plain words: pattern answers "what shape", required answers "is it there". Without required, empty always passes.
🗣 Як читати вголос Порожнє значення пройде, і це часто дивує навіть розробників. Pattern описує формат, а якщо значення немає, то й перевіряти нічого, тому для обов'язковості потрібен окремо required. Коли я бачу поле з pattern, я одразу додаю в тест-кейси порожній варіант і дивлюсь, що з ним робить бекенд, бо саме там найчастіше вилазить п'ятисотка. Ще я знаю, що шаблон неявно покриває все значення від початку до кінця, тож шість цифр у полі на п'ять не проскочать. А якщо регулярка некоректна, браузер її просто ігнорує, і це видно лише в консолі.
🗣 How to say it out loud An empty value passes, and that surprises even developers quite often. Pattern describes a format, and if there is no value there is nothing to check, so mandatory needs its own required. Whenever I see a field with a pattern, I add an empty case to my test cases straight away and watch what the backend does with it, because that is where a 500 tends to show up. I also know the pattern implicitly covers the whole value from start to end, so six digits will not slip into a five-digit field. And if the regex itself is broken, the browser just ignores it, which you only notice in the console.
44 У якому порядку перевіряти, що форма справді не приймає невалідне значення? In what order do you check that a form really rejects an invalid value?
ФормиForms Middle ▶

Що це. Перевірка має п'ять кроків, і кожен відповідає на своє питання. Перше: вводиш явно невалідне значення й тиснеш Submit. Друге: переконуєшся, що браузер або JS блокує відправку й показує підказку. Третє: знімаєш захист на клієнті — видаляєш required чи pattern у Elements або додаєш novalidate — і сабмітиш ще раз. Четверте: у Network бачиш, що запит таки пішов. П'яте: бекенд відповідає помилкою на те саме значення, і нічого не збереглось.

What it is. The check has five steps, and each answers its own question. First, type an obviously invalid value and press Submit. Second, confirm the browser or JS blocks the sending and shows a hint. Third, strip the client-side guard — delete required or pattern in Elements, or add novalidate — and submit again. Fourth, see in Network that the request did go out. Fifth, the backend answers with an error for the same value, and nothing was saved.

Навіщо саме такий порядок. Клієнтська валідація — це зручність для користувача, а не захист. Якщо зупинитись на червоній рамці, ти перевірив лише UI. Крок з обходом доводить, що запит із поганими даними реально може дійти до сервера, а останній — що сервер його відкидає. Без четвертого кроку незрозуміло, чи бекенд відмовив, чи запит просто не пішов.

Why this order. Client-side validation is a convenience for the user, not protection. Stopping at the red border means you have tested the UI only. The bypass step proves that a request with bad data can actually reach the server, and the last one proves the server rejects it. Without the fourth step you cannot tell whether the backend refused or the request simply never left.

Приклад. Поле «Кількість» має min="1". Ти вводиш -5, бачиш підказку, у Elements стираєш min, знову сабмітиш. У Network з'являється POST /api/cart з qty: -5. Правильно, якщо відповідь 400 або 422 з текстом помилки. Баг, якщо 200 і в кошику мінус п'ять товарів із від'ємною сумою.

Example. A "Quantity" field has min="1". You type -5, see the hint, erase min in Elements and submit again. Network shows POST /api/cart with qty: -5. A 400 or 422 with an error message is correct. A 200 with minus five items and a negative total in the cart is a bug.

Підводні камені. Статус 200 з полем error у тілі — теж сигнал: перевіряй і тіло, і те, що запис не з'явився в базі чи в UI після перезавантаження. Замість DevTools для кроку обходу зручніше взяти curl або Copy as cURL з Network — так запит повторюваний і його можна вставити в тікет.

Pitfalls. A 200 status with an error field in the body is a signal too: check the body, and check that the record did not appear in the database or in the UI after a reload. For the bypass step curl or Copy as cURL from Network is handier than DevTools edits — the request becomes repeatable and fits straight into the ticket.

Уточнююче питання. «Навіщо тоді взагалі тестувати фронтову валідацію?» Бо це UX і доступність: підказка, фокус, зрозумілий текст — окремі вимоги.

Follow-up question. "Why test front-end validation at all, then?" Because it is UX and accessibility: the hint, the focus and a clear message are requirements of their own.

Крок обходу: той самий запит без браузера
The bypass step: the same request without a browser
# copied from Network -> Copy as cURL, value changed to -5
curl -i -X POST 'https://shop.test/api/cart' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: session=abc123' \
  --data '{"productId": 42, "qty": -5}'

# expected
HTTP/1.1 422 Unprocessable Entity
{"error": "qty must be >= 1"}
🔧 Розбір синтаксису: -i виводить статус і заголовки відповіді, -X POST задає метод, кожен -H додає заголовок, а --data — тіло запиту. Зворотна похила риска в кінці рядка переносить команду. Блок після expected — не команда, а очікувана відповідь: код 422 і JSON з описом помилки.
🔧 Syntax breakdown: -i prints the response status and headers, -X POST sets the method, each -H adds a header and --data is the request body. The backslash at the end of a line continues the command. The block after expected is not a command but the response you expect: a 422 status and JSON describing the error.
💡 Простими словами: Тест валідації закінчується на відповіді бекенда, а не на червоній рамці поля.
💡 In plain words: A validation test ends at the backend response, not at a red border around the field.
🗣 Як читати вголос Я йду від інтерфейсу до сервера. Спочатку вводжу погане значення і переконуюсь, що фронт його зупиняє і показує зрозумілу підказку. Далі свідомо ламаю клієнтську перевірку, прибираю атрибут у DevTools або просто беру Copy as cURL, і відправляю те саме значення ще раз. Мені важливо побачити в Network, що запит реально пішов, інакше я не знаю, хто відмовив. І фінал — сервер повертає чотирьохсоту помилку, а в даних нічого не змінилось. Якщо бекенд прийняв мінус п'ять штук у кошик, це вже серйозний баг, а не косметика, і я так і пишу в тікеті.
🗣 How to say it out loud I go from the interface down to the server. First I type a bad value and make sure the front end stops it and shows a clear hint. Then I break the client-side check on purpose, I remove the attribute in DevTools or just grab Copy as cURL, and send the same value again. I need to see in Network that the request really went out, otherwise I do not know who refused it. And the finish is the server returning a four-hundred error with nothing changed in the data. If the backend accepted minus five items in the cart, that is a serious bug, not a cosmetic one.
45 Форма з novalidate і власною валідацією — що додається в скоуп тестування? A form with novalidate and custom validation — what gets added to the test scope?
ФормиForms Senior ▶

Що це. novalidate на <form> вимикає не лише вигляд нативної підказки, а всю поведінку браузера: блокування сабміту, фокус на першому невалідному полі, озвучення помилки скрінрідером. Тепер усе це має робити JS команди, і кожен пункт стає окремою вимогою, яку треба перевірити, бо сам собою він більше не з'явиться.

What it is. novalidate on a <form> removes not only the look of the native bubble but all of the browser's behaviour: blocking the submit, focusing the first invalid field, having the error announced by a screen reader. The team's JS now has to do all of that, and each item becomes a separate requirement to test, because it will no longer appear by itself.

Що додається. Фокус: після сабміту фокус переходить на перше невалідне поле, а не лишається на кнопці. Зв'язок помилки з полем: aria-invalid="true" і aria-describedby, що вказує на текст помилки, або live-регіон, щоб скрінрідер її озвучив. Таймінг: коли з'являється помилка — на blur, на input, на submit, і чи вона зникає після виправлення. Клавіатура: Enter у полі відправляє форму і запускає ту саму перевірку. Тексти: локалізація і однакові правила з бекендом.

What is added. Focus: after submit, focus moves to the first invalid field instead of staying on the button. Linking the error to the field: aria-invalid="true" and an aria-describedby pointing at the error text, or a live region, so the screen reader announces it. Timing: when the error shows — on blur, on input, on submit — and whether it goes away once fixed. Keyboard: Enter inside a field submits the form and triggers the same check. Texts: localisation and the same rules as the backend.

Приклад. Команда зробила гарні червоні підписи під полями. Мишкою все працює, а з клавіатури після Enter фокус лишається на кнопці, NVDA мовчить, і незрячий користувач не розуміє, чому форма не відправилась. Це функціональний баг доступності, хоча візуально все «як у макеті».

Example. The team built nice red captions under the fields. With a mouse everything works, but from the keyboard, after Enter, focus stays on the button, NVDA says nothing, and a blind user has no idea why the form did not go. That is a functional accessibility bug, even though visually it matches the mock-up.

Компроміс. Власна валідація дає контроль над дизайном і текстами, але переносить відповідальність за доступність на команду. Прагматичний варіант — лишити нативні атрибути для семантики і використати setCustomValidity() та Constraint Validation API для своїх повідомлень. Якщо ж novalidate обрано свідомо, тест-план має явно містити клавіатурний прогін і перевірку з скрінрідером, а не лише візуальні кейси.

Trade-off. Custom validation gives control over design and wording, but moves the responsibility for accessibility onto the team. A pragmatic middle ground keeps the native attributes for semantics and uses setCustomValidity() and the Constraint Validation API for custom messages. If novalidate was chosen deliberately, the test plan must explicitly include a keyboard pass and a screen reader check, not only visual cases.

Уточнююче питання. «Як це автоматизувати?» Перевіряти toBeFocused() на полі, атрибути ARIA і toHaveAccessibleDescription().

Follow-up question. "How do you automate it?" Assert toBeFocused() on the field, the ARIA attributes and toHaveAccessibleDescription().

Перевірка поведінки, яку раніше давав браузер
Checking the behaviour the browser used to provide
await page.getByLabel('Email').fill('not-an-email');
await page.getByLabel('Email').press('Enter');   // сабміт з клавіатури

const email = page.getByLabel('Email');
await expect(email).toBeFocused();                // фокус на першій помилці
await expect(email).toHaveAttribute('aria-invalid', 'true');
// текст помилки пов'язаний з полем через aria-describedby
await expect(email).toHaveAccessibleDescription(/коректний email/);
await page.getByLabel('Email').fill('not-an-email');
await page.getByLabel('Email').press('Enter');   // submit from the keyboard

const email = page.getByLabel('Email');
await expect(email).toBeFocused();                // focus on the first error
await expect(email).toHaveAttribute('aria-invalid', 'true');
// the error text is linked to the field through aria-describedby
await expect(email).toHaveAccessibleDescription(/valid email/);
🔧 Розбір синтаксису: press('Enter') імітує натискання клавіші у фокусованому полі, як це зробив би користувач. toBeFocused() чекає, поки елемент стане document.activeElement. toHaveAccessibleDescription(/.../) порівнює з регулярним виразом опис, який браузер обчислює з aria-describedby, тобто те, що почує скрінрідер.
🔧 Syntax breakdown: press('Enter') simulates a key press in the focused field, as a user would. toBeFocused() waits until the element becomes document.activeElement. toHaveAccessibleDescription(/.../) matches, against a regex, the description the browser computes from aria-describedby, which is what a screen reader will read out.
💡 Простими словами: novalidate знімає з браузера всю роботу з помилками. Усе, що він робив сам, тепер тестуєш як окрему вимогу, передусім з клавіатури.
💡 In plain words: novalidate takes all error handling away from the browser. Whatever it did on its own you now test as a separate requirement, keyboard first.
🗣 Як читати вголос Коли я бачу novalidate, я розумію, що команда забрала в браузера не картинку, а цілий шматок поведінки. Отже, в мій скоуп додається все, що раніше було безкоштовним: куди йде фокус після невдалого сабміту, чи озвучує скрінрідер помилку, чи прив'язаний текст до поля, коли помилка з'являється і коли зникає. Я обов'язково проходжу форму тільки з клавіатури і хоча б раз з NVDA чи VoiceOver. В автотестах перевіряю фокус і accessible description. Якщо команда не готова це підтримувати, я б запропонував лишити нативні атрибути і просто кастомізувати тексти.
🗣 How to say it out loud When I see novalidate, I know the team has taken away from the browser not a picture but a whole piece of behaviour. So everything that used to come for free joins my scope: where focus goes after a failed submit, whether the screen reader announces the error, whether the text is tied to the field, when an error appears and when it clears. I always walk through the form using only the keyboard, and at least once with NVDA or VoiceOver. In automation I check focus and the accessible description. If the team is not ready to maintain all that, I would suggest keeping the native attributes and just customising the texts.
46 Що насправді робить перемикач Offline і throttling у вкладці Network? What do the Offline toggle and throttling in the Network panel actually do?
ФормиForms Middle ▶

Що це. Offline у DevTools — емуляція всередині однієї вкладки браузера. Мережевий адаптер машини, інші вкладки і сам DevTools мережу не втрачають. Для сторінки ж запити падають з net::ERR_INTERNET_DISCONNECTED, navigator.onLine стає false, а на window спрацьовує подія offline. Throttling (пресети на кшталт Fast 4G, Slow 4G чи 3G і власні профілі) штучно додає затримку й обмежує пропускну здатність для запитів цієї вкладки.

What it is. Offline in DevTools is an emulation inside a single browser tab. The machine's network adapter, other tabs and DevTools itself keep their network. For the page, though, requests fail with net::ERR_INTERNET_DISCONNECTED, navigator.onLine becomes false and the offline event fires on window. Throttling (presets such as Fast 4G, Slow 4G or 3G, plus custom profiles) artificially adds latency and caps throughput for that tab's requests.

Навіщо. Це дешевий спосіб перевірити поведінку застосунку при поганій мережі: чи є банер «Немає з'єднання», чи не губляться введені дані, чи є таймаути і повтори, чи не висить спінер вічно. Для PWA головне питання якраз у тому, що service worker і HTTP-кеш далі віддають відповіді навіть в Offline — саме це й треба перевіряти.

Why it matters. It is a cheap way to check how the app behaves on a bad network: is there a "No connection" banner, is typed data kept, are there timeouts and retries, does the spinner hang forever. For a PWA the key question is precisely that the service worker and the HTTP cache keep serving responses even in Offline — which is exactly what you are there to check.

Приклад. Відкриваєш сторінку кошика, вмикаєш Offline, тиснеш «Оформити». Очікування: зрозуміле повідомлення і кнопка «Спробувати ще», а після вимкнення Offline — подія online і повтор. Реальний баг: застосунок показує білий екран, бо помилку fetch ніхто не обробив.

Example. You open the cart page, switch on Offline and press "Checkout". Expected: a clear message and a "Try again" button, and after Offline is off, the online event and a retry. Actual bug: the app shows a white screen because nobody handled the fetch error.

Підводні камені. Емуляція не ідентична реальній мережі: throttling у DevTools застосовується на рівні браузера, а не пакетів, тож втрати пакетів і нестабільний сигнал він не відтворює. Уже відкриті з'єднання, зокрема WebSocket, можуть поводитися інакше, ніж нові запити. І перемикач легко забути ввімкненим — жовтий трикутник на вкладці Network про це нагадує.

Pitfalls. The emulation is not identical to a real network: DevTools throttling works at the browser level, not on packets, so it does not reproduce packet loss or a flaky signal. Connections that are already open, WebSocket among them, may behave differently from new requests. And the toggle is easy to leave on — the yellow warning triangle on the Network tab is there to remind you.

Уточнююче питання. «Як це відтворити в автотесті?» Через context.setOffline(true) у Playwright або повільні відповіді через page.route.

Follow-up question. "How do you reproduce it in automation?" With context.setOffline(true) in Playwright, or slow responses via page.route.

Offline в автотесті Playwright
Offline in a Playwright test
test('кошик показує банер без мережі', async ({ page, context }) => {
  await page.goto('/cart');
  await context.setOffline(true);            // як перемикач Offline у DevTools
  expect(await page.evaluate(() => navigator.onLine)).toBe(false);

  await page.getByRole('button', { name: 'Оформити' }).click();
  await expect(page.getByRole('alert')).toContainText('Немає з\'єднання');

  await context.setOffline(false);           // мережа повернулась
  await page.getByRole('button', { name: 'Спробувати ще' }).click();
});
test('cart shows a banner without network', async ({ page, context }) => {
  await page.goto('/cart');
  await context.setOffline(true);            // like the Offline toggle in DevTools
  expect(await page.evaluate(() => navigator.onLine)).toBe(false);

  await page.getByRole('button', { name: 'Checkout' }).click();
  await expect(page.getByRole('alert')).toContainText('No connection');

  await context.setOffline(false);           // the network is back
  await page.getByRole('button', { name: 'Try again' }).click();
});
🔧 Розбір синтаксису: context.setOffline(true) вимикає мережу для всього BrowserContext у Playwright, це аналог перемикача Offline. page.evaluate(() => navigator.onLine) читає прапорець у браузері. getByRole('alert') шукає елемент з роллю alert, яку зазвичай має банер помилки, а toContainText перевіряє частину тексту з автоочікуванням.
🔧 Syntax breakdown: context.setOffline(true) cuts the network for the whole BrowserContext in Playwright, the counterpart of the Offline toggle. page.evaluate(() => navigator.onLine) reads the flag inside the browser. getByRole('alert') finds an element with the alert role, which an error banner usually has, and toContainText checks part of the text with auto-waiting.
💡 Простими словами: Offline у DevTools вимикає мережу тільки цій вкладці, а кеш і service worker продовжують працювати. Саме їхню поведінку ти й перевіряєш.
💡 In plain words: Offline in DevTools cuts the network for this tab only, while the cache and the service worker keep working. Their behaviour is exactly what you are testing.
🗣 Як читати вголос Перемикач Offline не чіпає мережу комп'ютера, він емулює відсутність з'єднання лише для однієї вкладки. Сторінка отримує навігатор офлайн, подію offline, а нові запити падають. При цьому кеш і service worker далі можуть віддавати відповіді, тож для PWA я якраз перевіряю, що саме показується без мережі. Throttling я використовую, щоб побачити, як застосунок поводиться на повільному 3G: чи є таймаут, чи не висить спінер. Але я пам'ятаю, що це наближення, втрати пакетів воно не імітує, тому критичні речі добре б ще раз перевірити на реальному телефоні.
🗣 How to say it out loud The Offline toggle does not touch the computer's network, it emulates a missing connection for one tab only. The page sees the navigator as offline, gets the offline event, and new requests fail. At the same time the cache and the service worker can still serve responses, so for a PWA that is exactly what I check: what shows up with no network. I use throttling to see how the app behaves on slow 3G, whether there is a timeout, whether a spinner hangs. But I remember it is an approximation that does not imitate packet loss, so critical flows are worth a second pass on a real phone.
47 Як зробити баг «нескінченний спінер на повільній мережі» доказовим? How do you make an "endless spinner on a slow network" bug provable?
ФормиForms Senior ▶

Що це. Такі баги часто закривають як «не відтворюється», бо в розробника швидкий офісний інтернет. Доказовість будується в чотири кроки. Перший: пройти сценарій без throttling і зафіксувати, що там усе гаразд — це базова лінія. Другий: увімкнути конкретний, названий профіль (наприклад, Slow 4G або власний «400 кбіт/с, 2000 мс») і повторити той самий сценарій. Третій: знайти в Network запит, який висить у стані pending або впав по таймауту. Четвертий: зберегти HAR і вказати профіль мережі в тікеті.

What it is. Bugs like this are often closed as "cannot reproduce", because the developer sits on fast office internet. Provability takes four steps. First, run the scenario with no throttling and record that it is fine there — the baseline. Second, turn on a specific, named profile (for instance Slow 4G, or a custom "400 kbit/s, 2000 ms") and repeat the same scenario. Third, find the request in Network that hangs as pending or dies on a timeout. Fourth, save the HAR and name the network profile in the ticket.

Навіщо саме так. Без базового прогону баг читається як «у мене все лагає». Без назви профілю розробник відтворює іншу мережу. А HAR — це повний запис запитів з таймінгами, заголовками й тілами, який відкривається в DevTools будь-кого без доступу до твого браузера.

Why this way. Without the baseline run the report reads as "everything is laggy for me". Without the profile name the developer reproduces a different network. And a HAR is a full record of requests with timings, headers and bodies that opens in anyone's DevTools without access to your browser.

Приклад. На Slow 4G GET /api/recommendations відповідає за 35 секунд, фронт має таймаут 30 секунд, fetch обривається через AbortController, але обробник помилки не прибирає стан loading. У Console видно AbortError, у Network — червоний рядок. Разом із HAR це готовий репорт із причиною.

Example. On Slow 4G GET /api/recommendations takes 35 seconds, the front end has a 30-second timeout, fetch is aborted through an AbortController, but the error handler never clears the loading state. The Console shows an AbortError, Network shows a red row. Together with the HAR that is a finished report with a cause.

Підводні камені і компроміси. Перед записом увімкни Preserve log, інакше редирект зітре історію. HAR містить cookies, токени в заголовках і тіла відповідей з персональними даними, тому перед вкладенням у тікет його треба санітизувати: у новіших Chrome за замовчуванням експортується версія без чутливих даних, але перевір файл сам. Ручний throttling погано повторюється, тож для регресії краще детермінований автотест із затримкою чи обривом конкретного запиту через page.route.

Pitfalls and trade-offs. Turn on Preserve log before recording, otherwise a redirect wipes the history. A HAR holds cookies, tokens in headers and response bodies with personal data, so it has to be sanitised before it goes into a ticket: newer Chrome versions export a version without sensitive data by default, but inspect the file yourself. Manual throttling is hard to repeat, so for regression a deterministic automated test that delays or aborts one specific request via page.route works better.

Уточнююче питання. «Що є очікуваною поведінкою?» Узгодь з продуктом таймаут, текст помилки і кнопку повтору — інакше «спінер» не має критерію «виправлено».

Follow-up question. "What is the expected behaviour?" Agree the timeout, the error text and a retry button with the product owner — otherwise the spinner fix has no definition of done.

Детерміноване відтворення замість ручного throttling
Deterministic reproduction instead of manual throttling
test('спінер зникає, якщо рекомендації не відповіли', async ({ page }) => {
  // тримаємо лише один запит довше за таймаут фронта (30 с)
  await page.route('**/api/recommendations', async route => {
    await new Promise(r => setTimeout(r, 35_000));
    await route.abort('timedout');
  });
  await page.goto('/product/42');

  await expect(page.getByRole('progressbar')).toBeHidden({ timeout: 40_000 });
  await expect(page.getByText('Не вдалося завантажити')).toBeVisible();
});
test('spinner disappears when recommendations never answer', async ({ page }) => {
  // hold just one request longer than the front-end timeout (30 s)
  await page.route('**/api/recommendations', async route => {
    await new Promise(r => setTimeout(r, 35_000));
    await route.abort('timedout');
  });
  await page.goto('/product/42');

  await expect(page.getByRole('progressbar')).toBeHidden({ timeout: 40_000 });
  await expect(page.getByText('Could not load')).toBeVisible();
});
🔧 Розбір синтаксису: page.route(glob, handler) перехоплює запити за шаблоном URL; ** означає будь-який префікс. new Promise(r => setTimeout(r, 35_000)) — пауза на 35 секунд, підкреслення в числі лише для читабельності. route.abort('timedout') обриває запит з мережевою помилкою таймауту. Опція { timeout: 40_000 } розширює очікування асерту.
🔧 Syntax breakdown: page.route(glob, handler) intercepts requests by URL pattern; ** means any prefix. new Promise(r => setTimeout(r, 35_000)) pauses for 35 seconds, and the underscore in the number is only for readability. route.abort('timedout') fails the request with a timeout network error. The { timeout: 40_000 } option extends how long the assertion waits.
💡 Простими словами: Базовий прогін, названий профіль мережі, конкретний запит і HAR — чотири речі, після яких «не відтворюється» вже не спрацює.
💡 In plain words: A baseline run, a named network profile, the specific request and a HAR — four things after which "cannot reproduce" no longer works.
🗣 Як читати вголос Я знаю, що такий баг легко закривають, тому збираю докази заздалегідь. Спершу проганяю сценарій на нормальній мережі, щоб показати, що проблема саме в швидкості. Потім вмикаю конкретний профіль, скажімо Slow 4G, і повторюю крок у крок. У Network шукаю той запит, що висить або відвалився, дивлюсь Console на помилки. Зберігаю HAR з увімкненим Preserve log, але перед тим, як прикріпити, чищу з нього токени й куки. А щоб баг не повернувся, пишу автотест, який затримує лише цей запит через route, бо ручний throttling погано повторюється.
🗣 How to say it out loud I know a bug like this is easy to close, so I gather the evidence up front. First I run the scenario on a normal network to show the problem really is about speed. Then I turn on a specific profile, say Slow 4G, and repeat it step for step. In Network I look for the request that hangs or drops, and I check the Console for errors. I save a HAR with Preserve log on, but before attaching it I strip the tokens and cookies out. And so the bug does not come back, I write an automated test that delays just that one request through route, because manual throttling is hard to repeat.
48 Що веб-тестер перевіряє щодо XSS, CSRF, CSP і mixed content? What does a web tester check regarding XSS, CSRF, CSP and mixed content?
ФормиForms Middle ▶

Що це. XSS — коли введений користувачем текст виконується як код на сторінці. CSRF — коли чужий сайт змушує браузер жертви відправити запит із її cookie. CSP (заголовок Content-Security-Policy) — список джерел, звідки сторінці дозволено вантажити скрипти, стилі й інші ресурси. Mixed content — HTTP-ресурси на HTTPS-сторінці.

What it is. XSS is when text a user entered runs as code on the page. CSRF is when a foreign site makes the victim's browser send a request carrying the victim's cookie. CSP (the Content-Security-Policy header) is the list of sources the page may load scripts, styles and other resources from. Mixed content is HTTP resources on an HTTPS page.

Що перевіряє QA. Для XSS — у тестовому середовищі вводиш у поля, URL-параметри й заголовки пейлоад на кшталт <img src=x onerror=alert(1)> і дивишся, чи він показаний як текст, чи виконався; правильна поведінка — екранування на виводі. Для CSRF — чи мають змінні запити CSRF-токен або перевірку Origin, і який SameSite у сесійної cookie. Для CSP — чи є заголовок і чи немає в ньому 'unsafe-inline' для скриптів. Для mixed content — чи не вантажаться картинки, скрипти чи API по http://.

What QA checks. For XSS, in a test environment, put a payload such as <img src=x onerror=alert(1)> into fields, URL parameters and headers and see whether it is shown as text or executed; the correct behaviour is escaping on output. For CSRF, check that state-changing requests carry a CSRF token or an Origin check, and which SameSite value the session cookie has. For CSP, check the header exists and does not allow 'unsafe-inline' for scripts. For mixed content, check no images, scripts or API calls go over http://.

Приклад. Ім'я в профілі зберігається як <b>Ann</b>, і в шапці воно стає жирним — отже, HTML вставляється без екранування, і до виконання скрипту один крок. CSP при цьому може врятувати: у Console з'явиться «Refused to execute inline script because it violates the following Content Security Policy directive», а пейлоад не спрацює.

Example. A profile name is saved as <b>Ann</b> and shows up bold in the header — so HTML is inserted without escaping, and script execution is one step away. CSP can still save the day here: the Console shows "Refused to execute inline script because it violates the following Content Security Policy directive", and the payload does nothing.

Підводні камені. Активний mixed content (скрипти, iframe, fetch) браузер блокує, а картинки сучасний Chrome намагається автоматично оновити до HTTPS, тож проблема помітна лише в Console. Перевірка безпеки — тільки на своєму стенді й з дозволу: пейлоад на продакшені чужого сервісу — це вже не тестування. І alert(1) може не показатись через CSP, хоча вразливість у рендері лишається.

Pitfalls. The browser blocks active mixed content (scripts, iframes, fetch), while modern Chrome tries to auto-upgrade images to HTTPS, so the problem is only visible in the Console. Security checks happen only on your own environment and with permission: a payload against someone else's production is no longer testing. And alert(1) may not pop up because of CSP, while the rendering flaw is still there.

Уточнююче питання. «Чи захищає від CSRF лише SameSite?» Частково: Lax блокує крос-сайтові POST, але токен лишається основним захистом.

Follow-up question. "Is SameSite alone enough against CSRF?" Partly: Lax blocks cross-site POSTs, but a token remains the main defence.

Заголовки, які варто побачити у відповіді
Headers worth seeing in the response
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.shop.test; img-src 'self' data:; frame-ancestors 'none'
Strict-Transport-Security: max-age=31536000; includeSubDomains
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
X-Content-Type-Options: nosniff
🔧 Розбір синтаксису: Кожен рядок — Назва: значення. У CSP директиви розділяються крапкою з комою: default-src 'self' — за замовчуванням лише свій origin, script-src додає дозволений CDN, frame-ancestors 'none' забороняє вбудовувати сторінку в чужі iframe. Strict-Transport-Security змушує браузер завжди ходити по HTTPS. У Set-Cookie атрибути йдуть після крапки з комою.
🔧 Syntax breakdown: Each line is Name: value. In CSP, directives are separated by semicolons: default-src 'self' means only the page's own origin by default, script-src adds an allowed CDN, frame-ancestors 'none' forbids embedding the page in foreign iframes. Strict-Transport-Security makes the browser always use HTTPS. In Set-Cookie the attributes follow the semicolons.
💡 Простими словами: Тестер не зламує систему, а перевіряє, що ввід показується як текст, змінні запити захищені, CSP є і все йде по HTTPS.
💡 In plain words: A tester does not break the system; they check that input renders as text, state-changing requests are protected, CSP is present and everything goes over HTTPS.
🗣 Як читати вголос Я не пентестер, але базову гігієну безпеки на вебі перевіряю завжди. На нашому стенді кидаю в поля простий пейлоад з картинкою та onerror і дивлюсь, чи він відмалювався як текст. Для форм, що щось змінюють, дивлюсь, чи є CSRF-токен і який SameSite у сесійної куки. У відповіді сторінки шукаю Content-Security-Policy і HSTS, а в Console — порушення CSP та попередження про mixed content. Важливий момент: якщо CSP заблокувала мій скрипт, я все одно заводжу баг, бо неекранований вивід нікуди не подівся, його просто прикрила друга лінія захисту.
🗣 How to say it out loud I am not a pentester, but I always check basic web security hygiene. On our test environment I drop a simple payload with an image and onerror into the fields and see whether it renders as plain text. For forms that change something, I look for a CSRF token and at the SameSite value of the session cookie. In the page response I look for Content-Security-Policy and HSTS, and in the Console for CSP violations and mixed content warnings. One important point: if CSP blocked my script I still file a bug, because the unescaped output is still there, it is just covered by a second line of defence.
49 Які сигнали шукати в Console і Application і як зібрати доказ для баг-репорту? Which signals do you look for in Console and Application, and how do you gather evidence for a bug report?
ФормиForms Junior ▶

Що це. Console показує помилки JavaScript (Uncaught TypeError), невдалі мережеві запити, порушення CSP, CORS і mixed content, а також попередження браузера. Application — стан сторінки: cookies з усіма прапорцями, Local Storage, Session Storage, IndexedDB, Cache Storage і зареєстровані service worker. Разом вони відповідають на питання «що зламалось» і «в якому стані був користувач».

What it is. Console shows JavaScript errors (Uncaught TypeError), failed network requests, CSP, CORS and mixed content violations, and browser warnings. Application shows the page's state: cookies with all their flags, Local Storage, Session Storage, IndexedDB, Cache Storage and registered service workers. Together they answer "what broke" and "what state the user was in".

Навіщо. Скриншот «кнопка не працює» не допомагає розробнику. Текст помилки зі стеком, назвою файлу та рядком, плюс стан сховища в момент бага, зазвичай скорочують розслідування з годин до хвилин. Якщо проєкт має source maps, стек вказує на рядок оригінального коду, а не на мініфікований бандл.

Why it matters. A screenshot saying "the button does not work" gives a developer nothing. An error text with a stack, a file name and a line, plus the storage state at the moment of the bug, usually cut the investigation from hours to minutes. If the project ships source maps, the stack points to a line of the original code rather than the minified bundle.

Приклад. Після логіну порожній дашборд. У Console — TypeError: Cannot read properties of undefined (reading 'map') з файлу Dashboard.tsx:48. В Application видно, що в Local Storage ключ user є, а permissions відсутній. Це вже гіпотеза для розробника, а не просто симптом.

Example. After login the dashboard is empty. Console shows TypeError: Cannot read properties of undefined (reading 'map') from Dashboard.tsx:48. Application shows that Local Storage has a user key but no permissions. That is a hypothesis for the developer, not just a symptom.

Підводні камені. Увімкни Preserve log, інакше після редиректу консоль очиститься. Фільтр рівнів за замовчуванням може ховати Verbose, а заздалегідь згорнуті групи повідомлень легко пропустити. Не копіюй у тікет значення токенів чи сесійних cookie відкритим текстом. І пам'ятай, що помилки з розширень браузера до застосунку не належать — перевір в інкогніто.

Pitfalls. Turn on Preserve log, otherwise a redirect clears the console. The default level filter may hide Verbose, and collapsed message groups are easy to miss. Do not paste token values or session cookies into a ticket in plain text. And remember that errors coming from browser extensions do not belong to the app — double-check in an incognito window.

Уточнююче питання. «Як швидко забрати дані з консолі?» Утиліта copy() кладе будь-яке значення в буфер обміну, а $0 — це елемент, вибраний в Elements.

Follow-up question. "How do you pull data out of the console quickly?" The copy() utility puts any value on the clipboard, and $0 is the element currently selected in Elements.

Сніпет для консолі DevTools
A DevTools console snippet
// 1. Ресурси, що вантажились найдовше (понад 1 с)
console.table(performance.getEntriesByType('resource')
  .filter(e => e.duration > 1000)
  .map(e => ({ url: e.name, ms: Math.round(e.duration) })));

// 2. Стан localStorage у буфер обміну — вставити в тікет
copy(JSON.stringify({ ...localStorage }, null, 2));

// 3. Хто слухає кліки на вибраному в Elements елементі
getEventListeners($0).click;
// 1. Resources that took longest to load (over 1 s)
console.table(performance.getEntriesByType('resource')
  .filter(e => e.duration > 1000)
  .map(e => ({ url: e.name, ms: Math.round(e.duration) })));

// 2. localStorage state to the clipboard — paste it into the ticket
copy(JSON.stringify({ ...localStorage }, null, 2));

// 3. Who listens to clicks on the element selected in Elements
getEventListeners($0).click;
🔧 Розбір синтаксису: performance.getEntriesByType('resource') повертає записи Resource Timing з полями name (URL) і duration. { ...localStorage } розгортає сховище в звичайний об'єкт, JSON.stringify(obj, null, 2) форматує його з відступом у два пробіли. copy(), $0 і getEventListeners() — утиліти Console API, вони працюють лише в консолі DevTools, а не в коді сторінки.
🔧 Syntax breakdown: performance.getEntriesByType('resource') returns Resource Timing entries with name (the URL) and duration fields. { ...localStorage } spreads the storage into a plain object, and JSON.stringify(obj, null, 2) formats it with two-space indentation. copy(), $0 and getEventListeners() are Console Utilities: they work only in the DevTools console, not in page code.
💡 Простими словами: Console каже, що зламалось, Application — у якому стані був користувач. Бери з обох текст, а не лише скриншот.
💡 In plain words: Console tells you what broke, Application tells you what state the user was in. Take text from both, not just a screenshot.
🗣 Як читати вголос Коли я заводжу баг на фронті, я майже завжди прикладаю щось із консолі. Спершу вмикаю Preserve log і відтворюю проблему, потім копіюю текст помилки разом зі стеком, бо назва файлу й рядок одразу підказують розробнику, куди дивитись. Далі йду в Application і дивлюсь, що лежить у cookies та localStorage, часто баг саме в тому, що якогось ключа немає. Для цього в мене є пара сніпетів: copy кладе стан сховища в буфер, а getEventListeners показує, чи взагалі щось висить на кнопці. Токени в тікет відкритим текстом, звісно, не вставляю.
🗣 How to say it out loud When I file a front-end bug I almost always attach something from the console. First I turn on Preserve log and reproduce the problem, then I copy the error text together with the stack, because the file name and line tell the developer right away where to look. Next I go to Application and see what sits in cookies and localStorage; quite often the bug is simply a missing key. I keep a couple of snippets for that: copy puts the storage state on the clipboard, and getEventListeners shows whether anything is attached to the button at all. Of course I never paste tokens into a ticket in plain text.