Що це. 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.
<!-- Відповідь сервера (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", який каже скрінрідеру, що елемент зайнятий.<!-- ... --> 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.Що це. Крапка в 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.
// 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.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.Що це. Семантичний тег описує призначення блоку, а не його вигляд. Частина таких тегів автоматично стає орієнтиром (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.
<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', ...) шукає друге лише всередині першого — так звужується область пошуку.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.Що це. Заголовки <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').
// Було: <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 Доставка», де пропуски рівнів видно одразу.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.Що це. 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.
<!-- Зламано: 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.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.Що це. Роль елементу дає не сам тег, а тег разом з атрибутами. За специфікацією 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.
<!-- Роль 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.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.Що це. Принцип простий: чим ближче локатор до того, як елемент знаходить людина, тим краще. Для кнопки «Оформити замовлення» порядок такий: роль і доступне ім'я (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.
// 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) — третій за рахунком серед сусідів.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.Що це. Такий локатор описує позицію вузла в дереві, а не його сенс: «спан у третьому діві всередині якогось діва». Проблема не в швидкості і не в тому, що це 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.
// Крихко: після нового банера це вже рядок знижки
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 ₴') звіряє точне значення.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.Що це. 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
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.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.Що це. 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.
// 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-корінь елемента-хоста.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.Що це. Коли кілька правил задають одну властивість одному елементу, браузер обирає переможця за каскадом: спершу важливість (!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.
/* 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 після значення піднімає декларацію над звичайними правилами, тож кнопка буде червоною./* ... */ 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.Що це. Три способи «сховати» елемент поводяться по-різному. 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.
.toast--closed { display: none; } /* no box, no space */
.tooltip--idle { visibility: hidden; } /* space kept, not visible */
.banner--faded { opacity: 0; } /* transparent, still clickable */
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');
page.locator(...) описує, як знайти елемент, а expect(locator) з методами toBeHidden(), toBeVisible() і toHaveCSS(назва, значення) — це web-first перевірки, які самі повторюються до таймауту. await потрібен, бо кожна перевірка асинхронна.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.Що це. Кожен елемент на сторінці — прямокутник з чотирьох шарів: вміст (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.
.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, який додає до картки інший спосіб рахувати ширину.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.Що це. Тег <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.
<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">
// 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(() => ...) виконує функцію всередині сторінки і повертає результат у тест.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.Що це. 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.
/* base styles: narrow screens */
.nav { display: none; }
.burger { display: block; }
@media (min-width: 768px) {
.nav { display: flex; }
.burger { display: none; }
}
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 перевертає очікування, тож один рядок перевіряє обидва стани.@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.Що це. Миша і палець генерують різні ланцюжки подій. На тачі спершу йдуть 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.
/* opens only on hover: no way in on a phone */
.menu .submenu { display: none; }
.menu:hover .submenu { display: block; }
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() імітує дотик пальцем..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.Що це. Перед кліком 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.
.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 */
// Лог 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 зупиняє анімації перед знімком.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.Що це. Повний декартів добуток «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.
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.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.Що це. 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 -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 в імені файлу змінюється з кожним білдом.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.Що це. Три підходи відрізняються тим, коли і де з'являється 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.
# 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 секунди.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.Що це. Послідовність така: сервер отримує запит і віддає готовий 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.
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 перевірки, які самі повторюються до таймауту. Остання пара рядків перевіряє наслідок кліку: лічильник кошика змінився.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.Що це. Гідратація (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.
// Варіант 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 робить кожну спробу короткою. Другий варіант годиться лише для ідемпотентних дій: кнопку «Оплатити» так повторювати не можна.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.Що це. 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 до взаємодії зі сторінкою
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 групує зсуви у вікна).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).Ситуація. Після релізу продакт каже «сайт став повільнішим», а один прогін 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.
{
"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 за цим конфігом.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.Що це. Це чотири місця, де браузер тримає дані сайту. 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.
// 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 лишає тільки назви баз.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.Що це. 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.
// Вкладка 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, якщо ключа немає. Коментарі після // показують, що поверне консоль у кожній вкладці. Той самий код в іншій вкладці звертається до свого, окремого сховища.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.Що це. Сервер ставить 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.
HTTP/1.1 200 OK
Set-Cookie: session=8f2c1e9a; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600
Content-Type: application/json
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 бере потрібну куку за назвою.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.Що це. Автоматично до запитів браузер додає лише 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.
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).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, 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.
// 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 — назва зміненого ключа.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.Що це. Замість проходити форму входу в кожному тесті сесію отримують один раз — найкраще одним 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
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 читає секрети зі змінних середовища.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.Що це. 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.
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() не візьме керування відкритими сторінками.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.Що це. 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.
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. Рядок з # — приклад виводу.-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.Що це. 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.
"timings": {
"blocked": 2890.4,
"dns": -1,
"connect": -1,
"ssl": -1,
"send": 0.3,
"wait": 84.2,
"receive": 311.7
}
blocked відповідає Queueing і Stalled, dns, connect, ssl — встановленню з'єднання, send — Request sent, wait — TTFB, receive — Content Download. Значення -1 означає, що фази не було: тут з'єднання перевикористане, а весь час з'їла черга.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.Що це. 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.
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 забороняє його кешувати.-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.Що це. Статус-коди діляться на п'ять груп: 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.
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 каже «кешуй, але щоразу перепитуй сервер».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".Що це. 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.
# 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 отримає помилку.# 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.Що це. 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.
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 у секундах, на скільки браузер може закешувати дозвіл.-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.Що це. Перше, що варто перевірити, — заголовок 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.
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
set-cookie містить значення і атрибути через крапку з комою: Path, Secure (лише HTTPS), HttpOnly (недоступна з JS), SameSite. Рядки з # — те, що ти побачиш у DevTools. Ланцюжок читається згори вниз: кука не збереглась → наступний запит без неї → 401 → редирект.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.Що це. Першим перевір 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.
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 каже браузеру не перевалідовувати файл навіть при оновленні сторінки.-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.Що це. Це задача на відсікання шарів: сервер, 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.
// 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);
await на верхньому рівні. fetch з { cache: 'no-store' } іде повз HTTP-кеш браузера (воркер при цьому може перехопити запит, якщо так запрограмований). navigator.serviceWorker.getRegistrations() повертає всі реєстрації для origin; r.active і r.waiting — активний і очікуючий воркери, ?. не падає, якщо їх немає. console.table малює результат таблицею. /version.json і window.APP_BUILD — умовні назви, у твоєму проєкті вони можуть бути інші.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.Що це. 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().
// <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('') — інверсія матчера: значення не має дорівнювати порожньому рядку.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.Що це. Обидва атрибути роблять поле незмінним для користувача, але поводяться по-різному. Поле з 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.
<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.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.Що це. Так, порожнє значення проходить. Атрибут 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().
// <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 змінює значення так само, як введення з клавіатури, для цілей валідації.\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.Що це. Перевірка має п'ять кроків, і кожен відповідає на своє питання. Перше: вводиш явно невалідне значення й тиснеш 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.
# 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 з описом помилки.-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.Що це. 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().
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, тобто те, що почує скрінрідер.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.Що це. 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.
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 перевіряє частину тексту з автоочікуванням.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.Що це. Такі баги часто закривають як «не відтворюється», бо в розробника швидкий офісний інтернет. Доказовість будується в чотири кроки. Перший: пройти сценарій без 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.
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 } розширює очікування асерту.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.Що це. 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.
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 атрибути йдуть після крапки з комою.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.Що це. 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.
// 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, а не в коді сторінки.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.