URL і кодування
Зміст
Кожен автотест, який відкриває сторінку чи стукає в API, починається з одного рядка — URL. Виглядає банально: скопіював адресу, вставив, працює. Але саме тут ховається чимала частка «магічних» падінь: тест зелений локально й червоний у CI, локатор не знаходить елемент після редіректу, перехоплення мережі (network interception) не спрацьовує, бо в реальному запиті пробіл закодований інакше, ніж ти очікував. Щоб такі речі не здавалися магією, треба бачити URL не як текст, а як структуру з чіткими правилами.
Формальна граматика URL описана в RFC 3986 (Uniform Resource Identifier (URI): Generic Syntax) — це базовий документ. Але браузери реалізують живий стандарт — WHATWG URL Standard, який у деталях відрізняється від RFC (наприклад, поводження з зайвими символами, нормалізація). Для AQA різниця відчутна: об’єкт URL у JavaScript і рядок в адресному рядку живуть за WHATWG, а серверна бібліотека мовою Python чи Java може розбирати той самий URL трохи інакше. Розбіжності на стиках — класичне джерело багів.
Анатомія URL
Повний URL складається з кількох частин, і кожна має свою роль. Схематично:
scheme://user:password@host:port/path?query#fragment
\____/ \____/ \_____/ \__/ \__/ \__/ \___/ \______/
схема юзер пароль хост порт шлях запит фрагмент
\_______________________/
authority (повноважна частина)
Реальний приклад з усіма частинами:
https://admin:secret@shop.example.com:8443/catalog/phones?sort=price&page=2#reviews
Розкладемо його по компонентах.
| Частина | Значення у прикладі | Роль |
|---|---|---|
| scheme (схема) | https | Протокол/спосіб доступу до ресурсу |
| userinfo | admin:secret | Дані для автентифікації (логін і пароль) |
| host (хост) | shop.example.com | Ім’я або IP сервера |
| port (порт) | 8443 | Порт на сервері |
| path (шлях) | /catalog/phones | Шлях до ресурсу в межах сервера |
| query (рядок запиту) | sort=price&page=2 | Параметри після ? |
| fragment (фрагмент) | reviews | Якір/стан у межах документа після # |
scheme (схема)
Схема — те, що йде до :// (для більшості мережевих протоколів), і саме вона визначає правила гри: як встановлювати з’єднання, який порт вважати типовим, чи є взагалі authority. Найпоширеніші для веб-тестувальника — http і https. Але схем багато: ftp, file (локальний файл, зазвичай без хоста: file:///Users/qa/report.html), mailto: (без // взагалі: mailto:qa@example.com), data: (вбудовані дані), ws/wss (WebSocket).
Чому це важливо для тестів: схема визначає безпекову межу. Cookie з атрибутом Secure не поїде по http. Змішаний контент (mixed content) — коли https-сторінка тягне http-ресурс — блокується або примусово підвищується браузером, і тест «незрозуміло чому» не бачить картинку чи скрипт. А ще схема входить у джерело (origin): http://site.com і https://site.com — це різні джерела, бо origin = схема + хост + порт. Політика одного джерела (same-origin policy) розводить їх по різних «пісочницях».
Дані автентифікації (userinfo): user:pass
Частина user:password@ перед хостом задає облікові дані для HTTP Basic Authentication. Браузер бере їх, кодує в Base64 й формує заголовок Authorization, а не передає у відкритому вигляді в рядку запиту. Причому, коли креденшели явно вказані в URL, він, як правило, надсилає заголовок превентивно — вже на першому запиті, не чекаючи на 401.
Нюанс. Чи надішле клієнт
Authorizationпревентивно з першим запитом, коли креденшели вказані в URL, — залежить від браузера/HTTP-клієнта: частина спершу шле запит без авторизації і додає заголовок лише після401. Перевіряється за хвилину в DevTools.
Синтаксис підтримується, але його активно не рекомендують: він засвічує пароль в історії й логах, і його експлуатують для фішингу (тому браузери приховують цю частину в адресному рядку й іноді показують попередження). Понад те, сучасні браузери блокують такі креденшели для підресурсних запитів. У тестах креденшели в URL краще не хардкодити. У Playwright для Basic Auth є окрема опція httpCredentials, яка робить те саме коректніше:
// playwright.config.ts — замість https://user:pass@host у goto()
use: {
httpCredentials: { username: 'admin', password: process.env.BASIC_PASS },
}
host (хост)
Хост — це або доменне ім’я (shop.example.com), або IP-адреса (127.0.0.1, [::1] для IPv6 у квадратних дужках). Саме хост браузер розв’язує через DNS у реальну IP-адресу перед з’єднанням.
Доменні імена не використовують percent-encoding. Замість цього неASCII-домени (кирилиця, ієрогліфи) кодуються окремим механізмом — Punycode (RFC 3492) в рамках IDNA. Домен приклад.укр браузер перетворює на ASCII-форму виду xn--.... Для AQA це означає, що асерт «поточний хост дорівнює приклад.укр» може провалитися, бо API браузера поверне punycode-форму, а не те, що бачить око.
port (порт)
Порт — число після хоста через двокрапку. Якщо його не вказано, береться типовий порт схеми:
| Схема | Типовий порт |
|---|---|
| http | 80 |
| https | 443 |
| ftp | 21 |
| ws | 80 |
| wss | 443 |
Ключова властивість: якщо порт збігається з типовим для схеми, у нормалізованому URL він зникає. Тобто https://example.com:443/ і https://example.com/ — один і той самий ресурс, і об’єкт URL віддасть порожній port. А от https://example.com:8443/ — інше джерело. Порт входить в origin, тому нестандартний порт (типово для локальних стендів і dev-серверів) ізолює web storage (localStorage/sessionStorage) в окремий контекст. А от cookie порт не ізолює: за RFC 6265 порт не входить у scope cookie, тож сервіси на різних портах одного хоста ділять cookie (розмежування йде лише по домену, шляху й, для Secure, схемі). Це варто тримати в голові, коли думаєш про ізоляцію стану між тестами.
path (шлях)
Шлях — це /catalog/phones у прикладі: ієрархія сегментів, розділених /. Порожній шлях для HTTP еквівалентний /. Саме path (разом із query) сервер використовує для маршрутизації — вирішує, який ресурс чи ендпоінт віддати.
Для тестів path — головний матеріал для локаторів навігації й для матчерів у перехопленні мережі. Тонкий момент — завершальний слеш: /catalog/phones і /catalog/phones/ сервер може вважати різними адресами й робити редірект з одного на інше (типово 301 або 308, залежно від фреймворка). Кожен зайвий редірект — це додатковий раунд очікування (wait), а на редіректах 301/302 браузер ще й може перетворити POST на GET і втратити тіло запиту (307/308, навпаки, метод і тіло зберігають). Якщо тест іноді падає на «повільному» кроці навігації, перша підозра — прихований редірект через слеш або регістр у шляху.
query (рядок запиту)
Query — усе після ?: sort=price&page=2. RFC 3986 визначає лише те, що query — це все між ? і #; його внутрішню структуру специфікація не диктує. Розбиття на пари ключ=значення, з’єднані &, — це домовленість, успадкована від HTML-форм. Тобто сам ? — структурний роздільник URL, а от & і = всередині query — угода застосунку. Сервер теоретично може парсити query як завгодно (історично, наприклад, роздільником пар подекуди був ;, а не &).
Практичні наслідки для автотестів:
- Порядок параметрів здебільшого не значущий для сервера, але значущий для рядкового порівняння URL і для матчерів мережі. Якщо матчиш запит по повному рядку
?a=1&b=2, а застосунок відправив?b=2&a=1, збіг не спрацює. Порівнюй розібрані параметри, а не сирий рядок. - Query — часте місце для керування станом (фільтри, пагінація, feature-флаги). Це зручний важіль для ізоляції стану між тестами: детермінований набір параметрів робить старт передбачуваним.
fragment (фрагмент)
Фрагмент — усе після #: reviews. Історично це якір на елемент із таким id на сторінці. У сучасних SPA фрагмент часто несе стан клієнтського роутінгу (hash-routing) — #/dashboard/users.
Головне, що треба закарбувати: фрагмент ніколи не йде на сервер. Про це — окремо нижче, бо це найчастіше джерело плутанини.
Що з URL іде на сервер, а що ні
Коли браузер відкриває URL, він використовує різні частини по-різному:
- scheme, host, port потрібні, щоб встановити з’єднання. Вони не передаються як «дані запиту», вони визначають, куди й як під’єднатися. Хост окремо дублюється в заголовку
Host. - path і query передаються серверу як ціль запиту (request-target).
- userinfo перетворюється на заголовок
Authorization, а не летить у рядку запиту. - fragment на сервер не передається взагалі.
Ось як виглядає реальний HTTP/1.1-запит для нашого URL (спрощено):
GET /catalog/phones?sort=price&page=2 HTTP/1.1
Host: shop.example.com
Authorization: Basic YWRtaW46c2VjcmV0
Зверни увагу: у рядку запиту є path і query, є Host, є Authorization — але #reviews немає ніде. Сервер фізично не знає про фрагмент. Обробкою фрагмента займається виключно браузер: скролить до елемента з відповідним id або віддає його JS-роутеру.
У HTTP/2 та HTTP/3 «рядка запиту» в такому вигляді немає — path і authority передаються через псевдозаголовки
:pathта:authority. Суть та сама: fragment і туди не потрапляє.
Практичні висновки, які рятують від годин дебагу:
- Зміна лише фрагмента не породжує новий серверний запит. Перехід
#reviews→#specsобробляється на клієнті (подіїhashchange/History API), сторінка не перезавантажується. Якщо тест чекає на мережевий запит після кліку по якірному посиланню — він може зависнути, бо запиту просто не буде. Тут потрібне очікування зміни DOM/URL, а не мережі. - У перехопленні мережі не можна матчити по фрагменту. Його немає в запиті, який бачить проксі чи
page.route. Матчинг будуй по path + query. - Логи сервера й моніторинг не бачать фрагмент. Якщо баг стосується конкретного стану після
#, у серверних логах його слідів не буде — шукай на клієнті.
Percent-encoding (відсоткове кодування)
URL за специфікацією може містити лише обмежений набір ASCII-символів. Усе інше — пробіли, кирилицю, більшість пунктуації, службові символи — треба закодувати. Механізм називається percent-encoding (він же URL-encoding): байт замінюється на % і два шістнадцяткові символи його значення.
Наприклад, пробіл має код 0x20, тому стає %20. Українська «і» (кодова точка U+0456) в UTF-8 — це два байти 0xD1 0x96, тому перетворюється на %D1%96. Тут ключове: спершу текст кодується в байти (для веб це майже завжди UTF-8), а потім кожен байт окремо переписується як %XX.
Резервовані й безпечні символи
Символи URL діляться на три категорії.
| Категорія | Символи | Поводження |
|---|---|---|
| Незарезервовані (unreserved) | A–Z a–z 0–9 - . _ ~ | Ніколи не потребують кодування |
| Зарезервовані (reserved) | : / ? # [ ] @ ! $ & ' ( ) * + , ; = | Мають структурну роль; кодуються, коли треба вжити їх як дані, а не як роздільник |
| Решта (пробіл, неASCII, керівні) | все інше | Завжди кодуються |
Логіка зарезервованих символів у тому, що вони — розділювачі структури. Слеш / розділяє сегменти шляху; ? починає query; & і = ділять пари в query; # починає фрагмент. Якщо ти хочеш, щоб такий символ був частиною значення, а не роздільником, його треба закодувати. Приклад: пошуковий запит red & blue у параметрі мусить стати q=red%20%26%20blue, інакше & розірве його на два параметри.
Чому пробіл стає %20 або +
Це найпопулярніше джерело плутанини, і відповідь така: існує дві різні угоди кодування пробілу, і вони застосовуються в різних місцях.
%20— це загальне percent-encoding пробілу за RFC 3986. Воно коректне будь-де в URL: у шляху, у query, деінде.+як пробіл — це правило окремого форматуapplication/x-www-form-urlencoded, яким серіалізуються HTML-форми. У цьому форматі пробіл записується як+, а справжній плюс — як%2B.
Тобто + означає пробіл тільки в контексті form-urlencoded query, який згенерувала форма чи серіалізатор форм. У шляху + — це літеральний плюс, а не пробіл. Через це ті самі дані виглядають по-різному:
Шлях: /search%20results/ (пробіл = %20)
Query від форми: /find?q=search+results (пробіл = +)
Query «загальний»: /find?q=search%20results (пробіл = %20, теж валідно)
Сервер, який приймає form-urlencoded, декодує + назад у пробіл. Але якщо ти вручну зібрав query з %20, а порівнюєш його з тим, що сформувала форма з + — рядки не збіжаться, хоч логічно значення однакове. Для AQA правило просте: порівнюй декодовані значення, а не сирі рядки. Спочатку декодуй обидві сторони, потім став асерт.
Кодування в JavaScript
У браузерному й Node-коді (а отже і в тестах на Playwright) кодуванням займаються кілька функцій, і їх легко переплутати.
| Інструмент | Що кодує | Пробіл → | Призначення |
|---|---|---|---|
encodeURIComponent | усе, крім A–Z a–z 0–9 - _ . ! ~ * ' ( ) | %20 | Один компонент (значення параметра, сегмент шляху) |
encodeURI | лишає незакодованими роздільники : / ? # @ & = + $ , ; (а от [ ] кодує) | %20 | Цілий URL, коли структура вже правильна |
URLSearchParams | усе потрібне для query | + | Складання й розбір query-рядка |
Показова різниця на прикладі. Значення red & blue:
encodeURIComponent('red & blue'); // 'red%20%26%20blue'
encodeURI('https://s.com/?q=red & blue'); // 'https://s.com/?q=red%20&%20blue' (& не закодовано — пастка!)
new URLSearchParams({ q: 'red & blue' }).toString(); // 'q=red+%26+blue' (пробіл як +)
Три різні результати для одного значення. encodeURI тут прямо небезпечний: він лишив & як роздільник, і значення розпадеться на два параметри. Тому для окремого значення завжди encodeURIComponent, а не encodeURI.
Ще одна пастка — URLSearchParams кодує пробіл як + і симетрично декодує + назад у пробіл. А от об’єкт URL для шляху використовує %20. Тому один і той самий рядок, зібраний двома шляхами, може відрізнятися саме тут:
const u = new URL('https://s.com/find');
u.searchParams.set('q', 'a b');
u.toString(); // 'https://s.com/find?q=a+b' → пробіл як +
decodeURIComponent('a+b'); // 'a+b' — увага: decodeURIComponent НЕ перетворює + на пробіл!
new URLSearchParams('q=a+b').get('q'); // 'a b' — а ось цей парсер перетворює
Останні два рядки — окрема міна. decodeURIComponent нічого не знає про form-urlencoded-угоду й лишає + як плюс. Правильно декодувати query-значення саме через URLSearchParams. Ця плутанина регулярно народжує баги в асертах на URL і в підготовці тестових даних.
Практичний висновок для перехоплення мережі: коли матчиш запит із неанглійськими символами чи пробілами, орієнтуйся на розібраний URL. Порівнювати сирі рядки — значить залежати від того, хто і як їх закодував.
// Playwright: надійний матч по значенню параметра, не по сирому рядку
await page.route('**/api/search**', route => {
const url = new URL(route.request().url());
if (url.searchParams.get('q') === 'кава з молоком') {
return route.fulfill({ body: JSON.stringify({ items: [] }) });
}
return route.continue();
});
Тут searchParams.get сам декодує %D0%BA%D0%B0... і +, тому в коді ти порівнюєш людський текст, а не байтову кашу.
Абсолютні й відносні URL
Абсолютний URL містить схему й достатньо інформації, щоб знайти ресурс без жодного контексту: https://shop.example.com/cart. Його можна вставити будь-де, і він означатиме одне й те саме.
Відносний URL — це посилання, яке набуває сенсу лише відносно базового URL (base URL). Він не самодостатній: item.html, ../cart, /help, ?page=2, #top. Браузер (чи бібліотека) добудовує його до абсолютного за правилами резолвінгу з RFC 3986.
Відносні посилання бувають кількох типів:
| Тип | Приклад | Що заміщує з бази |
|---|---|---|
| Відносний шлях | item.html, ../cart | Останній сегмент шляху бази |
| Абсолютний шлях | /help | Увесь шлях (від кореня хоста) |
| Мережевий (protocol-relative) | //cdn.com/app.js | Схема лишається з бази, решта — нова |
| Тільки query | ?page=2 | Замінює лише query бази |
| Тільки fragment | #top | Замінює лише фрагмент бази |
base URL і як резолвляться відносні шляхи
Базовий URL документа — це, за замовчуванням, той URL, з якого сторінку завантажено. Його можна перевизначити тегом <base href="..."> у <head>. У тестових фреймворках базу задають конфігом: у Playwright це baseURL, і тоді page.goto('/cart') резолвиться відносно неї.
Правило резолвінгу відносного шляху має один неочевидний нюанс: береться шлях бази, від нього відкидається все після останнього /, і на це місце підставляється відносний шлях. Тобто «файлова» частина бази викидається.
Візьмемо базу https://shop.example.com/catalog/phones/index.html:
| Відносний URL | Результат |
|---|---|
item.html | https://shop.example.com/catalog/phones/item.html |
./item.html | https://shop.example.com/catalog/phones/item.html |
../accessories/ | https://shop.example.com/catalog/accessories/ |
/cart | https://shop.example.com/cart |
//cdn.example.com/app.js | https://cdn.example.com/app.js |
?page=2 | https://shop.example.com/catalog/phones/index.html?page=2 |
#reviews | https://shop.example.com/catalog/phones/index.html#reviews |
Тепер підступний момент — завершальний слеш у базі змінює все. Порівняй дві бази й той самий відносний item.html:
База https://s.com/catalog/phones/ + item.html → https://s.com/catalog/phones/item.html
База https://s.com/catalog/phones + item.html → https://s.com/catalog/item.html
У другому випадку phones — це «файл», і резолвер його відкидає.
У Playwright резолвінг працює через той самий механізм — конструктор new URL(arg, baseURL), тобто відносно конфіга baseURL, а не поточної сторінки:
// playwright.config: baseURL: 'https://shop.example.com'
await page.goto('/catalog/phones'); // → https://shop.example.com/catalog/phones
await page.goto('item.html'); // → https://shop.example.com/item.html
Тут спрацьовує рівно те саме правило слеша. Якби baseURL був https://shop.example.com/catalog/phones (без слеша), то page.goto('item.html') дав би https://shop.example.com/catalog/item.html — сегмент phones відкинуто. Саме тому baseURL зі шляхом прийнято закінчувати слешем.
Варто розрізняти дві різні бази. Коли браузер резолвить клік по відносному посиланню, база — це URL документа (поточна сторінка або <base href>). Коли Playwright резолвить відносний аргумент goto, база — це конфіговий baseURL, незалежно від того, де зараз стоїть сторінка. Плутати ці дві бази — типова причина «переходу не туди». Для передбачуваності в тестах зазвичай беруть абсолютні від кореня шляхи (/...), які не залежать ні від слеша, ні від поточної адреси.
Чутливість частин URL до регістру
Різні частини URL по-різному ставляться до регістру, і це не косметика — від цього залежить, чи знайде тест елемент і чи не отримає він несподіваний редірект.
| Частина | Чутливість до регістру | Пояснення |
|---|---|---|
| scheme | Не чутлива | HTTP = http; нормалізується в нижній регістр |
| host | Не чутлива | Домени регістронезалежні (DNS); нормалізуються в нижній регістр |
| port | — | Просто число |
| path | Чутливий (за специфікацією) | Але фактично залежить від сервера/ОС |
| query | Чутливий | Значення й ключі — на розсуд застосунку, зазвичай значущі |
| fragment | Чутливий | Має збігатися з id/станом роутера з урахуванням регістру |
Тобто HTTPS://SHOP.Example.COM/ браузер нормалізує до https://shop.example.com/ — схема й хост опускаються в нижній регістр. А /Catalog і /catalog — це, за буквою специфікації, різні шляхи.
Слово «фактично» для path тут критичне. Специфікація каже, що path чутливий до регістру, але реальна поведінка визначається сервером і файловою системою:
- На Linux-серверах файлова система чутлива до регістру, тому
/Report.pdfі/report.pdf— різні файли, і промах у регістрі дасть 404. - На Windows/IIS та в багатьох CDN шлях часто трактують без урахування регістру, і обидва варіанти ведуть на той самий ресурс.
- Деякі сервери мають правило нормалізації регістру й роблять редірект
/Catalog→/catalog. Ось звідки береться «зайвий» редірект у трасі мережі.
Для percent-encoding є своя дрібниця: самі шістнадцяткові цифри регістронезалежні. %2F і %2f кодують один і той самий байт /. При нормалізації їх зазвичай приводять до верхнього регістру, але семантично вони рівні.
Що з цього виходить для автотестів:
- Хардкодити регістр хоста чи схеми в асертах безпечно — вони нормалізуються. А от порівнювати path чи query «в лоб» ризиковано: тест може зеленіти на одному сервері й падати на іншому саме через різну політику регістру. Це один із неочевидних чинників флаку (flakiness) при переїзді між середовищами (локально → CI → stage).
- Локатори за посиланнями й URL-асерти краще будувати нечутливо до регістру там, де застосунок цього не гарантує, або звіряти регістр із реальною поведінкою конкретного середовища, а не з припущенням.
- Якщо в трасі бачиш несподіваний 301/308 одразу після навігації — перевір регістр і завершальний слеш у path. Це дешевша гіпотеза, ніж «сервер тупить».
Складімо все в одну робочу перевірку, яку зручно тримати в голові під час тесту навігації:
const u = new URL(page.url());
u.protocol; // 'https:' (нормалізовано, нижній регістр)
u.hostname; // 'shop.example.com' (нижній регістр, punycode для неASCII)
u.port; // '' якщо типовий, інакше явне число
u.pathname; // '/catalog/phones' — регістр і слеш ЗБЕРІГАЮТЬСЯ
u.searchParams; // розбирає query; get() декодує %20 і +
u.hash; // '#reviews' — на сервер не пішло, живе лише тут
Ці шість рядків — практично весь розділ у мініатюрі: що нормалізується, що зберігається як є, де декодування вже зроблено за тебе, а де (fragment) даних узагалі не було на сервері. Коли наступного разу тест впаде на навігації або на матчі мережі, розклади URL на ці частини — і «магія» зазвичай зникає.
З яких частин складається URL і за що відповідає кожна?
Повний URL — це scheme://user:password@host:port/path?query#fragment. Схема визначає протокол і правила гри (як з'єднуватися, який порт типовий), userinfo — дані для Basic Auth, хост — ім'я чи IP сервера, порт — число після двокрапки, path — шлях до ресурсу в межах сервера, query — параметри після ?, fragment — якір чи стан клієнта після #. Частина user:password@host:port разом називається authority. Сильний кандидат ще додасть, що не всі схеми мають authority: mailto:qa@example.com обходиться без //, а file:///... — зазвичай без хоста. Для тестувальника кожна частина — окрема зона відповідальності: схема й хост визначають безпекову межу, path і query — маршрутизацію, fragment живе тільки в браузері.
Що з URL реально передається на сервер, а що ні?
На сервер як «дані запиту» їдуть тільки path і query — вони формують ціль запиту (request-target): GET /catalog/phones?sort=price HTTP/1.1. Scheme, host і port використовуються, щоб встановити з'єднання, — куди й як під'єднатися; хост окремо дублюється в заголовку Host. Userinfo перетворюється на заголовок Authorization, а не летить у рядку запиту. Fragment на сервер не передається взагалі — його обробляє виключно браузер. У HTTP/2 і HTTP/3 замість рядка запиту — псевдозаголовки :path та :authority, але суть та сама: фрагмент і туди не потрапляє. Практичний наслідок: у серверних логах не буде слідів стану після #, а матчери перехоплення мережі можуть спиратися лише на path і query.
Що буде, якщо не вказати порт в URL?
Візьметься типовий порт схеми: 80 для http і ws, 443 для https і wss, 21 для ftp. Якщо порт збігається з типовим, при нормалізації він зникає: https://example.com:443/ і https://example.com/ — один і той самий ресурс, і об'єкт URL віддасть порожній port. А от нестандартний порт лишається і, головне, входить в origin: https://example.com:8443 — це вже інше джерело. Для тестів це означає, що асерт на url.port для типового порту має очікувати порожній рядок, а не '443'.
Що таке фрагмент і чому клік по якірному посиланню не породжує мережевого запиту?
Фрагмент — усе після #: історично якір на елемент з відповідним id, у сучасних SPA — стан клієнтського роутінгу (hash-routing виду #/dashboard/users). Він ніколи не йде на сервер, тому перехід #reviews → #specs обробляється повністю на клієнті через hashchange або History API — сторінка не перезавантажується і жодного запиту не виникає. Для автотестів це класична пастка: якщо тест чекає на мережевий запит після кліку по якірному посиланню, він зависне, бо запиту просто не буде. Правильне очікування тут — зміна DOM або URL, а не мережі. І дзеркальний висновок: у page.route чи проксі матчити по фрагменту неможливо — його немає в запиті.
Що таке origin і чому http://site.com та https://site.com — різні джерела?
Origin — це трійка «схема + хост + порт», і політика одного джерела (same-origin policy) розводить різні origin по ізольованих «пісочницях». http://site.com і https://site.com відрізняються схемою, отже це різні джерела, хоч хост однаковий. Так само різними джерелами будуть https://site.com і https://site.com:8443 — через порт. Для тестів наслідки прямі: web storage (localStorage/sessionStorage) ізолюється по origin, тож сервіси на різних портах одного хоста мають окремі сховища. А ще схема визначає безпекову межу: cookie з атрибутом Secure не поїде по http, а mixed content (коли https-сторінка тягне http-ресурс) браузер блокує чи примусово підвищує — і тест «незрозуміло чому» не бачить ресурс.
Що таке percent-encoding і як кодуються неASCII-символи, наприклад кирилиця?
URL за специфікацією може містити лише обмежений набір ASCII-символів, тому все інше кодується percent-encoding'ом: байт замінюється на % і два шістнадцяткові символи його значення. Ключовий механізм — двоетапний: спершу текст кодується в байти (для веб це майже завжди UTF-8), потім кожен байт окремо переписується як %XX. Пробіл — один байт 0x20, тому стає %20; українська «і» в UTF-8 — два байти 0xD1 0x96, тому перетворюється на %D1%96. Сильний кандидат уточнить, що шістнадцяткові цифри регістронезалежні: %2F і %2f — той самий байт, при нормалізації їх зазвичай приводять до верхнього регістру. Для AQA це означає, що в трасі мережі кириличний параметр виглядає «байтовою кашею» — і порівнювати треба декодовані значення, а не сирі рядки.
Які символи в URL потрібно кодувати, а які — ні?
Символи діляться на три категорії. Незарезервовані (A–Z a–z 0–9 - . _ ~) не потребують кодування ніколи. Зарезервовані (: / ? # [ ] @ ! $ & ' ( ) * + , ; =) мають структурну роль — це роздільники: / ділить сегменти шляху, ? починає query, & і = ділять пари параметрів, # починає фрагмент — і кодуються тоді, коли їх треба вжити як дані, а не як роздільник. Усе інше (пробіли, неASCII, керівні символи) кодується завжди. Показовий приклад: пошуковий запит red & blue у параметрі мусить стати q=red%20%26%20blue, інакше незакодований & розірве значення на два параметри — і тест отримає не той запит, який очікував.
Чому пробіл в URL іноді %20, а іноді +?
Бо існують дві різні угоди кодування, які застосовуються в різних місцях. %20 — загальне percent-encoding пробілу за RFC 3986, коректне будь-де в URL: у шляху, у query, деінде. + як пробіл — правило окремого формату application/x-www-form-urlencoded, яким серіалізуються HTML-форми; у ньому справжній плюс кодується як %2B. Тобто + означає пробіл тільки в контексті form-urlencoded query, а в шляху + — це літеральний плюс. Сервер, який приймає form-urlencoded, декодує + назад у пробіл, тому q=search+results і q=search%20results логічно однакові — але як рядки не збігаються. Звідси правило для асертів: декодуй обидві сторони і порівнюй значення, а не сирі рядки, інакше тест залежатиме від того, хто і як закодував запит.
Чим відрізняються encodeURI та encodeURIComponent і коли яку функцію брати?
encodeURIComponent кодує все, крім невеликого списку безпечних символів, і призначена для одного компонента — значення параметра чи сегмента шляху. encodeURI лишає незакодованими структурні роздільники (: / ? # @ & = + $ , ;) і призначена для цілого URL, у якого структура вже правильна. Пастка в тому, що для окремого значення encodeURI небезпечна: encodeURI('https://s.com/?q=red & blue') лишить & як роздільник, і значення розпадеться на два параметри. Третій гравець — URLSearchParams: він складає і розбирає query, але кодує пробіл як +, а не %20. Тобто одне значення red & blue трьома інструментами дає три різні рядки. Правило просте: для окремого значення — завжди encodeURIComponent; для роботи з query цілком — URLSearchParams; а encodeURI — лише для вже структурно готового URL.
Чому decodeURIComponent('a+b') повертає a+b, і як правильно декодувати query-значення?
Бо decodeURIComponent реалізує тільки percent-decoding за RFC 3986 і нічого не знає про form-urlencoded-угоду, де + означає пробіл, — тому лишає плюс плюсом. А от парсер new URLSearchParams('q=a+b').get('q') поверне 'a b', бо він працює саме за правилами form-urlencoded. Симетрично і в інший бік: url.searchParams.set('q', 'a b') дасть у рядку q=a+b, тоді як об'єкт URL для шляху використовує %20. Правильний підхід — декодувати query-значення через URLSearchParams, а не через decodeURIComponent. У Playwright це виглядає так: у page.route береш new URL(route.request().url()) і порівнюєш url.searchParams.get('q') з людським текстом — метод сам декодує і %XX, і +. Ця плутанина регулярно народжує баги в URL-асертах і в підготовці тестових даних.
Чим абсолютний URL відрізняється від відносного і які типи відносних посилань бувають?
Абсолютний URL містить схему й достатньо інформації, щоб знайти ресурс без жодного контексту: https://shop.example.com/cart. Відносний набуває сенсу лише відносно базового URL і добудовується до абсолютного за правилами резолвінгу з RFC 3986. Типів кілька: відносний шлях (item.html, ../cart) заміщує останній сегмент шляху бази; абсолютний шлях (/help) — увесь шлях від кореня хоста; protocol-relative (//cdn.com/app.js) бере з бази лише схему; ?page=2 замінює тільки query; #top — тільки фрагмент. Базовий URL документа — це адреса, з якої сторінку завантажено, але його можна перевизначити тегом base у head, а в Playwright базу задає конфіговий baseURL. Для передбачуваності в тестах зазвичай беруть шляхи від кореня (/...) — вони не залежать ні від слеша, ні від поточної адреси.
Як резолвиться відносний шлях і чому завершальний слеш у baseURL критичний?
Правило: береться шлях бази, від нього відкидається все після останнього /, і на це місце підставляється відносний шлях — «файлова» частина бази викидається. Тому база https://s.com/catalog/phones/ плюс item.html дає https://s.com/catalog/phones/item.html, а база без слеша https://s.com/catalog/phones — уже https://s.com/catalog/item.html: сегмент phones сприйнято як «файл» і відкинуто. У Playwright page.goto з відносним аргументом резолвиться через той самий механізм new URL(arg, baseURL) — саме тому baseURL зі шляхом прийнято закінчувати слешем. Окремо треба розрізняти дві бази: браузер резолвить клік по посиланню відносно URL документа, а Playwright резолвить аргумент goto відносно конфігового baseURL, незалежно від того, де зараз стоїть сторінка. Плутанина між цими двома базами — типова причина «переходу не туди».
Які частини URL чутливі до регістру і як це впливає на автотести?
Scheme і host — нечутливі: HTTPS://SHOP.Example.COM/ нормалізується до https://shop.example.com/. Path, query і fragment — чутливі за специфікацією, але для path слово «фактично» критичне: реальна поведінка залежить від сервера й файлової системи. На Linux /Report.pdf і /report.pdf — різні файли, і промах у регістрі дасть 404; на Windows/IIS і в багатьох CDN обидва варіанти ведуть на той самий ресурс; а деякі сервери нормалізують регістр редіректом /Catalog → /catalog. Для тестів висновок такий: хардкодити регістр схеми й хоста в асертах безпечно, а порівнювати path «в лоб» ризиковано — тест може зеленіти на одному сервері й падати на іншому, і це неочевидний чинник флаку при переїзді локально → CI → stage. Якщо в трасі мережі несподіваний 301/308 одразу після навігації — спершу перевір регістр і завершальний слеш у path, це дешевша гіпотеза, ніж «сервер тупить».
Як працює user:password@ в URL і чому так робити не варто?
Ця частина задає облікові дані для HTTP Basic Authentication: браузер кодує їх у Base64 і формує заголовок Authorization, а не передає у відкритому вигляді в рядку запиту. Коли креденшели явно вказані в URL, браузер, як правило, шле заголовок превентивно — з першим запитом, не чекаючи на 401; втім, це залежить від клієнта, частина спершу пробує без авторизації, і це за хвилину перевіряється в DevTools. Синтаксис активно не рекомендують: пароль засвічується в історії й логах, його експлуатують для фішингу (тому браузери ховають цю частину в адресному рядку), а для підресурсних запитів сучасні браузери такі креденшели взагалі блокують. У тестах хардкодити креденшели в URL не варто — у Playwright для Basic Auth є окрема опція httpCredentials, яка робить те саме коректніше і бере пароль з env-змінної.
Чому асерт «поточний хост дорівнює приклад.укр» може провалитися?
Бо доменні імена не використовують percent-encoding: неASCII-домени кодуються окремим механізмом — Punycode (RFC 3492) в рамках IDNA. Домен приклад.укр браузер перетворює на ASCII-форму виду xn--..., і саме її повернуть API браузера — наприклад, url.hostname. Око бачить кирилицю в адресному рядку, а тест порівнює punycode-рядок з кириличним очікуванням і падає. Рішення — або очікувати punycode-форму, або нормалізувати обидві сторони до одного вигляду перед порівнянням. Це той випадок, коли «баг» насправді сидить в асерті, а не в застосунку.
Чим редіректи 301/302 небезпечніші за 307/308 у контексті тестів?
На редіректах 301/302 браузер може перетворити POST на GET і втратити тіло запиту, тоді як 307/308 зберігають і метод, і тіло. Типові генератори таких редіректів — дрібниці в path: завершальний слеш (/catalog/phones проти /catalog/phones/ сервер може вважати різними адресами й редіректити, типово 301 або 308 залежно від фреймворка) і регістр (/Catalog → /catalog). Кожен зайвий редірект — додатковий раунд очікування, тому тест, який «іноді падає на повільному кроці навігації», часто впирається саме в прихований редірект. Діагностика проста: відкрити трасу мережі й подивитися, чи немає 3xx одразу після навігації, а потім звірити слеш і регістр у шляху.
Чи ізолює нестандартний порт стан між тестами?
Частково — і саме ця асиметрія підступна. Порт входить в origin, тому web storage (localStorage/sessionStorage) на https://host:8443 і https://host:9443 — два окремі контексти. А от cookie порт не ізолює: за RFC 6265 порт не входить у scope cookie, розмежування йде лише по домену, шляху й (для Secure) схемі — тож сервіси на різних портах одного хоста ділять cookie. Практичний наслідок для тестів: паралельні стенди на різних портах одного хоста можуть «протікати» один в одного сесійними cookie, хоча localStorage у них розділений. Якщо тест на одному стенді раптом бачить сесію іншого — перше, що варто перевірити, чи не живуть обидва на одному хості з різними портами.
Чим RFC 3986 відрізняється від WHATWG URL Standard і де ця різниця стріляє?
RFC 3986 — базовий документ, що описує формальну граматику URI, а браузери реалізують живий стандарт WHATWG URL Standard, який у деталях від RFC відрізняється — наприклад, у поводженні з зайвими символами та нормалізації. Об'єкт URL у JavaScript і рядок в адресному рядку живуть за WHATWG, а серверна бібліотека на Python чи Java може розбирати той самий URL трохи інакше. Розбіжності на стиках клієнт–сервер — класичне джерело багів: обидві сторони «праві» за своєю специфікацією, але бачать різні результати парсингу. Сюди ж лягає внутрішня структура query: RFC визначає лише, що query — це все між ? і #, а розбиття на пари ключ=значення через & — домовленість від HTML-форм (історично роздільником подекуди був ;), тож сервер теоретично може парсити query як завгодно. Для AQA висновок: не покладатися, що всі учасники ланцюжка розберуть URL однаково, і в спірних місцях звіряти саме розібрані компоненти на кожній стороні.
Три кейси, де URL перестає бути «просто текстом»: розклад адреси на частини в DevTools із перевіркою, що фрагмент на сервер не йде; матч мережі по значенню параметра замість сирого рядка (пастка %20 vs +); і резолвінг відносного goto через baseURL, де завершальний слеш вирішує все. Скрізь — що дивитися і чому.
Кейс 1. Розкладаєш URL на частини — і «магія» зникає
Тест чекає на мережевий запит після кліку по якірному посиланню й зависає до таймауту. Перш ніж лізти в код, розклади URL на компоненти прямо в консолі DevTools — об'єкт URL зробить нормалізацію за тебе й покаже, що куди йде.
const u = new URL('HTTPS://SHOP.Example.COM:443/Catalog/phones?sort=price&page=2#reviews');
u.protocol; // 'https:' — схема опущена в нижній регістр
u.hostname; // 'shop.example.com' — хост теж нормалізовано
u.port; // '' — 443 типовий для https, тому зник
u.pathname; // '/Catalog/phones' — регістр і слеш ЗБЕРЕЖЕНО як є
u.searchParams.get('sort'); // 'price'
u.hash; // '#reviews' — живе лише тут, на сервер не піде
Що дивитися і чому:
protocolіhostname— у нижньому регістрі. Схема й хост регістронезалежні й нормалізуються, тому асерт на них хардкодити безпечно. А отpathnameлишився/Catalogз великої — path за специфікацією чутливий до регістру, і на Linux-сервері/Catalogта/catalogце різні ресурси (промах →404або несподіваний301/308).portпорожній. Типовий порт схеми в нормалізованому URL зникає:https://host:443/іhttps://host/— один ресурс. Якби порт був8443, він лишився б і дав би інше джерело (origin = схема + хост + порт).hashтримається тільки в об'єкті URL. Фрагмент ніколи не летить на сервер: перехід#reviews → #specsне породжує нового HTTP-запиту, його обробляє браузер (hashchange/History API). Ось чому тест, що чекає на мережу після кліку по#-посиланню, зависає — запиту просто немає. Тут потрібне очікування зміни DOM/URL, а не мережі.
Реальний запит підтверджує це буквально — curl -v покаже рядок запиту без фрагмента:
curl -v 'https://shop.example.com/Catalog/phones?sort=price&page=2#reviews' 2>&1 | grep '^> GET'
> GET /Catalog/phones?sort=price&page=2 HTTP/1.1
У request-target є path і query — а #reviews немає ніде. Сервер фізично не знає про фрагмент, тож і в серверних логах його слідів не шукай.
Кейс 2. Матч мережі: пробіл як %20 чи + ламає порівняння рядків
Перехоплення (page.route) не спрацьовує на пошуковому запиті з пробілом чи кирилицею, хоча URL «на око» правильний. Причина — дві різні угоди кодування пробілу: %20 (загальний percent-encoding) і + (формат application/x-www-form-urlencoded, яким серіалізуються форми). Той самий запит різні збирачі закодують по-різному:
encodeURIComponent('кава з молоком'); // '%D0%BA%D0%B0%D0%B2%D0%B0%20%D0%B7%20...' — кирилиця байтами, пробіл %20
new URLSearchParams({ q: 'кава з молоком' }).toString(); // 'q=%D0%BA...+%D0...' — пробіл як +
Якщо матчиш по сирому рядку ?q=...%20..., а застосунок відправив +, збіг не спрацює — хоч значення логічно однакове. Плюс порядок параметрів у рядку теж може відрізнятися. Рішення — матчити по розібраному значенню, а не по сирому URL:
test('мок пошуку не залежить від кодування пробілу', async ({ page }) => {
await page.route('**/api/search**', (route) => {
const url = new URL(route.request().url());
// searchParams.get сам декодує і %20, і + у людський текст
if (url.searchParams.get('q') === 'кава з молоком') {
return route.fulfill({ contentType: 'application/json', body: '{"items":[]}' });
}
return route.continue();
});
await page.goto('/search');
await page.getByRole('searchbox').fill('кава з молоком');
await page.getByRole('button', { name: 'Знайти' }).click();
await expect(page.getByText('Нічого не знайдено')).toBeVisible();
});
Що дивитися і чому:
searchParams.get('q')повертає людський текст. Він декодує і%D0%BA..., і+, тож у коді ти порівнюєш'кава з молоком', а не байтову кашу. Умова стійка до того, хто і як закодував запит.- Не пиши
if (route.request().url().includes('q=кава%20з...')). Сирий рядок прив'язує тебе до конкретного кодування пробілу й порядку параметрів — класичне джерело «мок не спрацював». decodeURIComponentтут не помічник. Він нічого не знає про form-urlencoded:decodeURIComponent('a+b')віддасть'a+b', а не'a b'. Декодувати query-значення треба саме черезURLSearchParams.
Кейс 3. Відносний goto і завершальний слеш у baseURL
Тест «переходить не туди»: page.goto('item.html') веде на несподівану адресу. Причина — правило резолвінгу відносного шляху з RFC 3986: береться baseURL, від нього відкидається все після останнього /, і на це місце стає відносний шлях. Завершальний слеш вирішує все.
// playwright.config.ts
export default defineConfig({
use: { baseURL: 'https://shop.example.com/catalog/phones' }, // БЕЗ слеша в кінці
});
test('резолвінг залежить від слеша в baseURL', async ({ page }) => {
await page.goto('/cart'); // → https://shop.example.com/cart (шлях від кореня — слеш не важить)
await page.goto('item.html'); // → https://shop.example.com/catalog/item.html ← 'phones' відкинуто!
});
Якби baseURL закінчувався слешем (.../catalog/phones/), той самий item.html дав би .../catalog/phones/item.html. Саме тому baseURL зі шляхом прийнято закінчувати слешем.
Що дивитися і чому:
- Playwright резолвить відносний
gotoвід конфіговогоbaseURL, а не від поточної сторінки. Це інша база, ніж у браузера (той бере URL документа або<base href>). Плутати ці дві бази — типова причина «переходу не туди». - Абсолютний від кореня шлях (
/cart) не залежить ні від слеша, ні від поточної адреси. Для передбачуваності в тестах беруть саме такі шляхи, а не відносніitem.html. - Несподіваний
301/308одразу після навігації — перша підозра на завершальний слеш або регістр у path (/Catalog → /catalog). Кожен зайвий редірект — це додатковий раунд очікування, звідки й береться «повільний» флакі-крок. Це дешевша гіпотеза, ніж «сервер тупить».
Окремо про Basic Auth: креденшели в URL (https://user:pass@host) у тестах не хардкодь — вони засвічують пароль у логах, а браузери блокують їх для підресурсних запитів. У Playwright для цього є httpCredentials, що робить те саме коректніше:
// playwright.config.ts — замість https://admin:secret@host у goto()
use: {
httpCredentials: { username: 'admin', password: process.env.BASIC_PASS },
}Анатомія URL
- Можу розкласти URL на частини
scheme://user:password@host:port/path?query#fragmentі назвати роль кожної, аauthority— як хост із портом і опційним userinfo. - Розумію, що origin — це схема + хост + порт, тому
http://site.comіhttps://site.com(як і різні порти) — різні джерела; порт ізолюєlocalStorage/sessionStorage, але cookie порт не ізолює (RFC 6265). - Розумію, що типовий порт схеми (443 для https, 80 для http) при нормалізації зникає, тож
https://example.com:443/іhttps://example.com/— той самий ресурс, аURL.portбуде порожній. - Можу пояснити userinfo (
user:pass@): це креденшели для HTTP Basic Auth, які браузер кодує вAuthorization, а не шле в query; у Playwright для цього єhttpCredentials. - Розумію, чому неASCII-домени кодуються Punycode (
xn--...), а не percent-encoding, і що асерт «хост дорівнюєприклад.укр» впаде, бо API поверне punycode-форму.
Що з URL іде на сервер
- Знаю, що на сервер ідуть path і query (як request-target), хост дублюється в
Host, userinfo стаєAuthorization, а scheme/host/port лише встановлюють зʼєднання. - Можу впевнено сказати: фрагмент (
#...) на сервер не йде ніколи — його обробляє тільки браузер (скрол доidабо hash-роутер), і в HTTP/2/3 через:path/:authorityвін теж не потрапляє. - Розумію практичний наслідок: зміна лише фрагмента не породжує серверний запит (працюють
hashchange/History API), тому тест, що чекає на мережу після кліку по якорю, зависне — чекати треба зміну DOM/URL. - Знаю, що в перехопленні мережі не можна матчити по фрагменту (його немає в запиті) і що серверні логи його не бачать — баг стану після
#шукаю на клієнті.
Percent-encoding
- Розумію механізм percent-encoding: текст спершу кодується в байти (для веб майже завжди UTF-8), потім кожен байт переписується як
%XX(пробіл →%20, «і» U+0456 →%D1%96). - Можу розвести unreserved (
A–Z a–z 0–9 - . _ ~, ніколи не кодуються), reserved (структурні роздільники, кодуються коли треба вжити як дані) і решту (завжди кодується). - Можу пояснити пастку пробілу:
%20— загальне правило RFC 3986 (валідне будь-де), а+— це пробіл тільки у форматіapplication/x-www-form-urlencoded; у шляху+— літеральний плюс. - Знаю різницю трьох JS-функцій:
encodeURIComponent(один компонент, пробіл →%20),encodeURI(лишає роздільники — небезпечний для значень, бо не кодує&),URLSearchParams(пробіл →+); і щоdecodeURIComponentНЕ перетворює+на пробіл. - Можу сформулювати головне правило AQA: порівнюй декодовані значення, а не сирі рядки, і в перехопленні орієнтуйся на розібраний URL (
searchParams.get), а не на байтову кашу.
Абсолютні й відносні URL
- Можу відрізнити абсолютний URL (самодостатній, зі схемою) від відносного (
item.html,../cart,/help,?page=2,#top), який набуває сенсу лише відносно base URL. - Розумію нюанс резолвінгу відносного шляху: від бази відкидається все після останнього
/(«файлова» частина), тому завершальний слеш у базі змінює результат. - Можу розвести дві бази: браузер резолвить клік відносно URL документа (чи
<base href>), а Playwright —gotoчерезnew URL(arg, baseURL)відносно конфіговогоbaseURL, а не поточної сторінки; для передбачуваності беру абсолютні від кореня шляхи (/...).
Чутливість до регістру та нормалізація
- Знаю, що scheme і host регістронезалежні й нормалізуються в нижній регістр, а path, query і fragment — чутливі до регістру.
- Розумію, що «фактична» чутливість path залежить від сервера/ОС: Linux-ФС чутлива (промах у регістрі → 404), Windows/IIS і CDN часто ні, а дехто робить редірект
/Catalog→/catalog. - Можу пояснити, чому несподіваний 301/308 одразу після навігації — привід перевірити регістр і завершальний слеш у path (дешевша гіпотеза, ніж «сервер тупить»).
- Знаю, що шістнадцяткові цифри в percent-encoding регістронезалежні (
%2F=%2f), і що це один із неочевидних чинників флаку при переїзді локально → CI → stage.
Яка частина URL ніколи не передається на сервер?
Питання
Що входить в origin (джерело) і чому http://site.com та https://site.com — різні джерела?