vyvchy
    Теми розділу

    03 · Веб і мережі для AQA

    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Протокол/спосіб доступу до ресурсу
    userinfoadmin: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 (порт)

    Порт — число після хоста через двокрапку. Якщо його не вказано, береться типовий порт схеми:

    СхемаТиповий порт
    http80
    https443
    ftp21
    ws80
    wss443

    Ключова властивість: якщо порт збігається з типовим для схеми, у нормалізованому 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 на сервер не передається взагалі.

    URL

    scheme + host + port

    path + query

    userinfo

    fragment

    Встановлення зʼєднання (куди й як)

    Ціль запиту: GET /path?query

    Заголовок Authorization

    Лишається у браузері — на сервер не йде

    URL

    scheme + host + port

    path + query

    userinfo

    fragment

    Встановлення зʼєднання (куди й як)

    Ціль запиту: GET /path?query

    Заголовок Authorization

    Лишається у браузері — на сервер не йде

    Ось як виглядає реальний 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.

    Символ: пробіл / «і»

    Байти UTF-8: 0x20 / 0xD1 0x96

    Кожен байт → %XX: %20 / %D1%96

    Символ: пробіл / «і»

    Байти UTF-8: 0x20 / 0xD1 0x96

    Кожен байт → %XX: %20 / %D1%96

    Резервовані й безпечні символи

    Символи 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, який згенерувала форма чи серіалізатор форм. У шляху + — це літеральний плюс, а не пробіл. Через це ті самі дані виглядають по-різному:

    form-urlencoded query (HTML-форма)

    шлях або загальний URL

    Кодуємо пробіл

    Контекст?

    + (плюс)

    %20

    form-urlencoded query (HTML-форма)

    шлях або загальний URL

    Кодуємо пробіл

    Контекст?

    + (плюс)

    %20

    Шлях:               /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.htmlhttps://shop.example.com/catalog/phones/item.html
    ./item.htmlhttps://shop.example.com/catalog/phones/item.html
    ../accessories/https://shop.example.com/catalog/accessories/
    /carthttps://shop.example.com/cart
    //cdn.example.com/app.jshttps://cdn.example.com/app.js
    ?page=2https://shop.example.com/catalog/phones/index.html?page=2
    #reviewshttps://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 на ці частини — і «магія» зазвичай зникає.