diff --git a/AGENTS.md b/AGENTS.md index 04bd24c..14c9572 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md Это учебный HTTP-сервер, на который **живьём ходят студенты** из курсов Хекслета: -HTTP API, протокол HTTP, Postman, js-playwright. Уроки и самостоятельные работы +HTTP API, протокол HTTP, Postman, js-playwright, курсы Selenium и Playwright. Уроки и самостоятельные работы цитируют его ответы дословно, вплоть до значений полей. Отсюда главное следствие, из которого растёт почти всё остальное в этом файле: @@ -269,6 +269,16 @@ make deploy APP=http_example HOST=timeweb SKIP=caddy,cron **Значения полей только на латинице.** Сервер один на все локали курсов, и русский текст в ответе читался бы как ошибка у испанского студента. По-русски здесь пишутся комментарии в коде и спецификациях, не данные. +## Учебные приложения для UI-тестов это тоже текст уроков + +Префиксы `/java-selenium`, `/python-selenium` и `/python-playwright` обслуживают курсы Selenium и Playwright, ru и es. У каждого курса своё приложение (библиотека, трекер задач, табло вылетов), описанное в `apps` в `custom-server/src/ui-pages.ts`. Общее у них только устройство входа, сессии и флеш-сообщений. Уроки находят элементы по `id`, классам и `data-test` и сравнивают надписи дословно, поэтому переименование `#username`, `#book-moby-dick`, `.figcaption h5` или текста флеш-сообщения ломает примеры курса. Страницы и локаторы перечислены в README, их держит `test/ui-pages.test.ts`. + +- **Тема приложения выбрана под уроки курса.** Новая страница появляется из сценария приложения: в библиотеке перетаскивают книгу на полку, на табло время зависит от часового пояса. Страница-тренажёр без сценария сюда не идёт. +- **Слова Hexlet в приложениях нет.** Это вымышленные сервисы, и студент тестирует их как чужой продукт. +- **Перетаскивание собрано на `mousedown` и `mouseup`.** `Actions.dragAndDrop` в Selenium эмулирует мышь, а события HTML5 `dragstart` и `drop` браузер на неё не шлёт, поэтому `draggable` пример молча не срабатывал бы. +- **Сессия это подписанная кука с путём курса, и курс записан в её значении.** Урок про куки сохраняет их после входа, открывает страницу в новом браузере, подкладывает куки руками и идёт в закрытый раздел. Кука одного курса не открывает раздел другого, неподписанное значение не открывает ничего. +- **Учётки напечатаны на страницах входа и в уроках.** Их смена это правка курсов. + ## Набор данных не меняется `POST` и `DELETE` отвечают 201 и 204, но список задач остаётся прежним: создание diff --git a/README.md b/README.md index 0e9a591..bc7469d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # http-example -Учебный HTTP-сервер Хекслета: [https://http.hexlet.app](https://http.hexlet.app). На него **живьём ходят студенты** из курсов HTTP API, протокол HTTP, Postman и js-playwright, а уроки и самостоятельные работы цитируют его ответы дословно, вплоть до значений полей. +Учебный HTTP-сервер Хекслета: [https://http.hexlet.app](https://http.hexlet.app). На него **живьём ходят студенты** из курсов HTTP API, протокол HTTP, Postman, js-playwright и курсов Selenium и Playwright, а уроки и самостоятельные работы цитируют его ответы дословно, вплоть до значений полей. Отсюда главное свойство проекта: **ответы сервера это контент курса**, а не деталь реализации. Правка данных здесь делает неверным текст урока в другом репозитории, и по коду это не видно. Поэтому правила работы с проектом собраны в [AGENTS.md](./AGENTS.md), и читать их стоит до первой правки — там же разобрано, почему наборы данных такого размера, почему они не меняются и какие коды ответов несут уроки. @@ -19,6 +19,39 @@ Обработчик `body` принимает JSON, `application/x-www-form-urlencoded` и multipart. Для примера с файлом он возвращает его содержимое как строку UTF-8. Лимиты multipart допускают один файл до 64 КиБ и до десяти текстовых полей по 4 КиБ. Исходное тестовое изображение взято из [httpbin](https://httpbin.org/image/png), который распространяется под [лицензией MIT](https://github.com/postmanlabs/httpbin/blob/master/LICENSE). +## Учебные приложения для UI-тестов + +Курсы Selenium и Playwright тестируют маленькие приложения под префиксом своего курса. У каждого своя предметная область, и страницы в нём появились из её сценариев. Уроки ищут элементы и проверяют тексты дословно, поэтому локаторы и надписи этих страниц тоже контент курса. + +**`/java-selenium`, библиотека City Library.** Курс разбирает действия пользователя, окна и фреймы, и у каждого действия в библиотеке есть смысл. + +| Путь | Сценарий | +| --- | --- | +| `/java-selenium` | каталог, `h1` «City Library» | +| `/java-selenium/login` → `/my-shelf` | вход читателя `reader` / `books123`, полка с выданными книгами | +| `/java-selenium/covers` | обложки `.figure`, название `.figcaption h5` видно при наведении, двойной клик добавляет книгу в избранное | +| `/java-selenium/shelves` | книга `#book-moby-dick` перетаскивается на полку `#shelf-reading` | +| `/java-selenium/search` | поиск `#search` фильтрует каталог, Enter печатает число найденного в `#result` | +| `/java-selenium/return` | кнопка `#return-book` спрашивает `confirm` перед возвратом | +| `/java-selenium/preview` | «Open preview» открывает отрывок в новом окне | +| `/java-selenium/reader` | глава книги во фрейме `chapter`, абзац `#content` | + +**`/python-selenium`, трекер Task Board.** Курс разбирает сессию, куки, ожидания и мобильное меню. + +| Путь | Сценарий | +| --- | --- | +| `/python-selenium` | промо-страница, первая ссылка «Sign in» после названия | +| `/python-selenium/login` → `/tasks` | вход `student` / `tasks123`, список задач, меню `#menu-button` с пунктами `.menu-item` | + +**`/python-playwright`, табло Departures.** Курс настраивает контекст браузера, и табло на эти настройки отвечает. + +| Путь | Сценарий | +| --- | --- | +| `/python-playwright` | табло `#departures`, время вылета печатается в часовом поясе браузера, на узком экране строки складываются в карточки | +| `/python-playwright/login` → `/trips` | вход пассажира `passenger` / `flight123`, «My trips» | + +У всех трёх одинаковое устройство входа. Форма `#login` с полями `#username` и `#password` и кнопкой `button.primary[data-test=login-button]`, флеш-сообщение `#flash`, выход по `/<курс>/logout`. Приложения описаны конфигом `apps` в `custom-server/src/ui-pages.ts`, страницы лежат фрагментами в `custom-server/assets/ui-pages/<курс>/`, префикс в них записан как `{{prefix}}`. + ## Prerequisites - [mise](https://mise.jdx.dev) (ставит Node, pnpm, Caddy и jactionlint по `mise.toml`) diff --git a/custom-server/assets/ui-pages/java-selenium/covers.html b/custom-server/assets/ui-pages/java-selenium/covers.html new file mode 100644 index 0000000..7b773c8 --- /dev/null +++ b/custom-server/assets/ui-pages/java-selenium/covers.html @@ -0,0 +1,15 @@ +
+

Book covers

+

Hover over a cover to see the book. Double-click the title to add it to favorites.

+
MD
Moby Dick

Herman Melville

+
TI
Treasure Island

Robert Louis Stevenson

+
PP
Pride and Prejudice

Jane Austen

+

+
+ diff --git a/custom-server/assets/ui-pages/java-selenium/index.html b/custom-server/assets/ui-pages/java-selenium/index.html new file mode 100644 index 0000000..13c38f4 --- /dev/null +++ b/custom-server/assets/ui-pages/java-selenium/index.html @@ -0,0 +1,13 @@ +
+

City Library

+

Find a book, put it on your shelf and read the first chapter online.

+ +
diff --git a/custom-server/assets/ui-pages/java-selenium/preview.html b/custom-server/assets/ui-pages/java-selenium/preview.html new file mode 100644 index 0000000..57323c8 --- /dev/null +++ b/custom-server/assets/ui-pages/java-selenium/preview.html @@ -0,0 +1,5 @@ +
+

Moby Dick

+

Herman Melville, 1851.

+ Open preview +
diff --git a/custom-server/assets/ui-pages/java-selenium/preview_moby_dick.html b/custom-server/assets/ui-pages/java-selenium/preview_moby_dick.html new file mode 100644 index 0000000..78ac37c --- /dev/null +++ b/custom-server/assets/ui-pages/java-selenium/preview_moby_dick.html @@ -0,0 +1,4 @@ +
+

Moby Dick: preview

+

Call me Ishmael. Some years ago, never mind how long precisely, having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.

+
diff --git a/custom-server/assets/ui-pages/java-selenium/reader.html b/custom-server/assets/ui-pages/java-selenium/reader.html new file mode 100644 index 0000000..a3cf0ea --- /dev/null +++ b/custom-server/assets/ui-pages/java-selenium/reader.html @@ -0,0 +1,5 @@ +
+

Chapter reader

+

Moby Dick, chapter 1. Loomings.

+ +
diff --git a/custom-server/assets/ui-pages/java-selenium/reader_chapter.html b/custom-server/assets/ui-pages/java-selenium/reader_chapter.html new file mode 100644 index 0000000..fda882f --- /dev/null +++ b/custom-server/assets/ui-pages/java-selenium/reader_chapter.html @@ -0,0 +1,11 @@ + + + + + Chapter 1. Loomings + + +

Chapter 1. Loomings

+

Call me Ishmael.

+ + diff --git a/custom-server/assets/ui-pages/java-selenium/return.html b/custom-server/assets/ui-pages/java-selenium/return.html new file mode 100644 index 0000000..6450258 --- /dev/null +++ b/custom-server/assets/ui-pages/java-selenium/return.html @@ -0,0 +1,12 @@ +
+

Return a book

+

Moby Dick, Herman Melville. Due in 7 days.

+ +

+
+ diff --git a/custom-server/assets/ui-pages/java-selenium/search.html b/custom-server/assets/ui-pages/java-selenium/search.html new file mode 100644 index 0000000..d75f9d5 --- /dev/null +++ b/custom-server/assets/ui-pages/java-selenium/search.html @@ -0,0 +1,30 @@ +
+

Catalog search

+ + + +

+
+ diff --git a/custom-server/assets/ui-pages/java-selenium/shelves.html b/custom-server/assets/ui-pages/java-selenium/shelves.html new file mode 100644 index 0000000..e6b8e83 --- /dev/null +++ b/custom-server/assets/ui-pages/java-selenium/shelves.html @@ -0,0 +1,36 @@ +
+

Shelves

+

Drag a book to the shelf you are reading from.

+
+
+
Want to read
+
Moby Dick
+
Treasure Island
+
+
+
Reading
+
+
+
+ diff --git a/custom-server/assets/ui-pages/python-playwright/index.html b/custom-server/assets/ui-pages/python-playwright/index.html new file mode 100644 index 0000000..08182de --- /dev/null +++ b/custom-server/assets/ui-pages/python-playwright/index.html @@ -0,0 +1,23 @@ +
+ My trips +

Departures

+

Times are shown in your time zone.

+ + + + + + + + + +
FlightDestinationTimeStatus
SK 204LisbonOn time
SK 318OsloBoarding
SK 527IstanbulDelayed
+
+ diff --git a/custom-server/assets/ui-pages/python-selenium/index.html b/custom-server/assets/ui-pages/python-selenium/index.html new file mode 100644 index 0000000..d8a1574 --- /dev/null +++ b/custom-server/assets/ui-pages/python-selenium/index.html @@ -0,0 +1,5 @@ +
+ Sign in +

Task Board

+

Plan the tests you are going to write and tick them off one by one.

+
diff --git a/custom-server/assets/ui-pages/style.css b/custom-server/assets/ui-pages/style.css new file mode 100644 index 0000000..c453d6b --- /dev/null +++ b/custom-server/assets/ui-pages/style.css @@ -0,0 +1,30 @@ +body { font: 18px/1.5 system-ui, sans-serif; margin: 40px auto; padding: 0 24px; max-width: 760px; color: #18212d; } +a { color: #315acc; } +.brand { font-weight: 600; text-decoration: none; } +label { display: block; margin-top: 16px; } +input { display: block; box-sizing: border-box; width: 100%; margin-top: 8px; padding: 12px; font: inherit; border: 1px solid #9ca6b4; border-radius: 6px; } +button, .button { display: inline-block; margin-top: 16px; padding: 10px 24px; font: inherit; color: white; background: #315acc; border: 0; border-radius: 6px; cursor: pointer; text-decoration: none; } +.flash { margin: 16px 0; padding: 12px 16px; border-radius: 6px; } +.flash.success { background: #dff3e4; } +.flash.error { background: #fbe1e1; } +#menu { display: flex; gap: 16px; margin-top: 8px; } +#menu[hidden] { display: none; } +.figure { display: inline-block; margin: 16px 16px 0 0; vertical-align: top; width: 140px; } +.cover { height: 180px; display: grid; place-items: center; font-size: 36px; color: white; background: #6b4f3a; border-radius: 4px 10px 10px 4px; } +.figcaption { display: none; } +.figure:hover .figcaption { display: block; } +.figcaption h5 { margin: 8px 0 0; font-size: 16px; } +.figcaption p { margin: 0; font-size: 14px; color: #5c6675; } +#shelves { display: flex; gap: 24px; } +.shelf { flex: 1; min-height: 180px; padding: 12px; background: #f1ece4; border: 2px solid #b9a88f; border-radius: 8px; } +.shelf header { font-weight: 600; margin-bottom: 8px; } +.book { margin-top: 8px; padding: 8px 12px; background: white; border: 1px solid #b9a88f; border-radius: 4px; cursor: move; user-select: none; } +iframe { width: 100%; height: 200px; border: 1px solid #9ca6b4; border-radius: 6px; } +#departures { width: 100%; border-collapse: collapse; } +#departures th, #departures td { padding: 8px; text-align: left; border-bottom: 1px solid #d5dbe3; } +/* На узком экране строки табло складываются в карточки. */ +@media (max-width: 600px) { + #departures thead { display: none; } + #departures tr { display: block; margin-bottom: 12px; padding: 8px; border: 1px solid #d5dbe3; border-radius: 8px; } + #departures td { display: block; border: 0; padding: 2px 0; } +} diff --git a/custom-server/src/index.ts b/custom-server/src/index.ts index b54eed0..42d6c54 100644 --- a/custom-server/src/index.ts +++ b/custom-server/src/index.ts @@ -11,6 +11,7 @@ import fastifyCookie from '@fastify/cookie'; import rawConfig from '../../app.config.json' with { type: 'json' }; import setUpRpc from './rpc.ts'; import setUpBodyExamples from './body-examples.ts'; +import setUpUiPages from './ui-pages.ts'; import setUpSpec from './openapi-spec.ts'; // title в app.config.json нет, и документация всегда отдавалась без заголовка. @@ -113,6 +114,7 @@ export default async (app: FastifyInstance, _options: unknown) => { setUpStaticAssets(app); await app.register(setUpBodyExamples); + await app.register(setUpUiPages); await setupDocs(app); diff --git a/custom-server/src/ui-pages.ts b/custom-server/src/ui-pages.ts new file mode 100644 index 0000000..d1ffd34 --- /dev/null +++ b/custom-server/src/ui-pages.ts @@ -0,0 +1,262 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +// Учебные приложения для курсов Selenium и Playwright. Префикс это курс, как у +// остальных приложений сервера, и у каждого курса своя предметная область: +// страницы появляются из её сценариев, а не из списка элементов для тренировки. +// Уроки цитируют локаторы и тексты этих страниц дословно, так же как уроки HTTP +// цитируют ответы API. +// +// Общее у приложений только устройство: вёрстка, вход, сессия и флеш-сообщение +// собираются здесь. Страницы лежат фрагментами в assets/ui-pages/<курс>/, +// префикс курса в них записан как {{prefix}}. + +interface Page { + file: string; + // Страница без общей вёрстки, например содержимое фрейма. + bare?: boolean; +} + +interface UiApp { + title: string; + credentials: { username: string; password: string }; + securePath: string; + texts: { + loginHeading: string; + secureHeading: string; + loggedIn: string; + invalid: string; + mustLogIn: string; + loggedOut: string; + }; + secureBody: string; + // Выхода в меню нет: ссылка на /logout на странице одна, и find_element по + // href не находит скрытый пункт меню раньше видимой кнопки. + menu: { label: string; path: string }[]; + pages: Record; +} + +export const apps: Record = { + 'java-selenium': { + title: 'City Library', + credentials: { username: 'reader', password: 'books123' }, + securePath: '/my-shelf', + texts: { + loginHeading: 'Reader login', + secureHeading: 'My shelf', + loggedIn: 'Welcome back! Your shelf is ready.', + invalid: 'Wrong username or password.', + mustLogIn: 'Log in to see your shelf.', + loggedOut: 'You logged out of the library.', + }, + secureBody: ``, + menu: [ + { label: 'Catalog', path: '' }, + { label: 'Book covers', path: '/covers' }, + ], + pages: { + '': { file: 'index.html' }, + '/covers': { file: 'covers.html' }, + '/shelves': { file: 'shelves.html' }, + '/search': { file: 'search.html' }, + '/return': { file: 'return.html' }, + '/preview': { file: 'preview.html' }, + '/preview/moby-dick': { file: 'preview_moby_dick.html' }, + '/reader': { file: 'reader.html' }, + '/reader/chapter': { file: 'reader_chapter.html', bare: true }, + }, + }, + 'python-selenium': { + title: 'Task Board', + credentials: { username: 'student', password: 'tasks123' }, + securePath: '/tasks', + texts: { + loginHeading: 'Sign in', + secureHeading: 'My tasks', + loggedIn: 'You are signed in.', + invalid: 'Invalid username or password.', + mustLogIn: 'Sign in to see your tasks.', + loggedOut: 'You are signed out.', + }, + secureBody: ``, + menu: [ + { label: 'Inbox', path: '/tasks' }, + { label: 'Today', path: '/tasks' }, + ], + pages: { + '': { file: 'index.html' }, + }, + }, + 'python-playwright': { + title: 'Departures', + credentials: { username: 'passenger', password: 'flight123' }, + securePath: '/trips', + texts: { + loginHeading: 'Passenger login', + secureHeading: 'My trips', + loggedIn: 'Welcome aboard!', + invalid: 'Wrong username or password.', + mustLogIn: 'Log in to see your trips.', + loggedOut: 'You logged out.', + }, + secureBody: ``, + menu: [{ label: 'Departures', path: '' }], + pages: { + '': { file: 'index.html' }, + }, + }, +}; + +const sessionCookie = 'ui-session'; +const flashCookie = 'ui-flash'; +const cookieOptionsFor = (prefix: string) => ({ path: prefix, httpOnly: true, sameSite: 'lax' }) as const; + +const assetsDir = path.join(import.meta.dirname, '../assets/ui-pages'); + +const escape = (text: string) => + text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); + +const layout = (app: UiApp, prefix: string, body: string) => ` + + + + + ${app.title} + + + + ${app.title} +${body} + + +`; + +const flashBlock = (flash: { kind: string; message: string } | undefined) => + flash ? `
${escape(flash.message)}
\n` : ''; + +const takeFlash = (req: FastifyRequest, res: FastifyReply, prefix: string) => { + const raw = req.cookies[flashCookie]; + if (!raw) return undefined; + res.clearCookie(flashCookie, cookieOptionsFor(prefix)); + const [kind, message] = raw.split(':', 2) as ['success' | 'error', string]; + return { kind, message }; +}; + +const redirectWithFlash = ( + res: FastifyReply, + prefix: string, + url: string, + kind: 'success' | 'error', + message: string, +) => res.setCookie(flashCookie, `${kind}:${message}`, cookieOptionsFor(prefix)).redirect(`${prefix}${url}`); + +// В значении сессии стоит курс: урок про куки подкладывает их руками, и кука +// одного курса не должна открывать закрытый раздел другого. +const sessionValue = (app: UiApp, prefix: string) => `${app.credentials.username}@${prefix}`; + +const isLoggedIn = (req: FastifyRequest, app: UiApp, prefix: string) => { + const raw = req.cookies[sessionCookie]; + if (!raw) return false; + const { valid, value } = req.unsignCookie(raw); + return valid && value === sessionValue(app, prefix); +}; + +const html = (res: FastifyReply, body: string) => res.type('text/html; charset=utf-8').send(body); + +const setUpApp = (server: FastifyInstance, course: string, app: UiApp) => { + const prefix = `/${course}`; + const cookieOptions = cookieOptionsFor(prefix); + const { credentials, texts } = app; + + for (const [route, page] of Object.entries(app.pages)) { + const fragment = readFileSync(path.join(assetsDir, course, page.file), 'utf8').replaceAll('{{prefix}}', prefix); + const body = page.bare ? fragment : layout(app, prefix, fragment); + server.get(`${prefix}${route}`, (_req, res) => html(res, body)); + } + + server.get(`${prefix}/login`, (req, res) => { + const flash = takeFlash(req, res, prefix); + return html( + res, + layout( + app, + prefix, + `${flashBlock(flash)}
+

${texts.loginHeading}

+

Use ${credentials.username} and ${credentials.password} to log in.

+
+ + + + + +
+
`, + ), + ); + }); + + server.post(`${prefix}/authenticate`, (req, res) => { + const { username, password } = (req.body ?? {}) as Record; + if (username !== credentials.username || password !== credentials.password) { + return redirectWithFlash(res, prefix, '/login', 'error', texts.invalid); + } + res.setCookie(sessionCookie, sessionValue(app, prefix), { ...cookieOptions, signed: true }); + return redirectWithFlash(res, prefix, app.securePath, 'success', texts.loggedIn); + }); + + server.get(`${prefix}${app.securePath}`, (req, res) => { + if (!isLoggedIn(req, app, prefix)) { + return redirectWithFlash(res, prefix, '/login', 'error', texts.mustLogIn); + } + const flash = takeFlash(req, res, prefix); + const menu = app.menu + .map((item) => ` ${item.label}`) + .join('\n'); + return html( + res, + layout( + app, + prefix, + `${flashBlock(flash)}
+ + +

${texts.secureHeading}

+ ${app.secureBody} + Logout +
+ `, + ), + ); + }); + + server.get(`${prefix}/logout`, (_req, res) => { + res.clearCookie(sessionCookie, cookieOptions); + return redirectWithFlash(res, prefix, '/login', 'success', texts.loggedOut); + }); +}; + +export default async function setUpUiPages(server: FastifyInstance) { + for (const [course, app] of Object.entries(apps)) { + setUpApp(server, course, app); + } +} diff --git a/test/ui-pages.test.ts b/test/ui-pages.test.ts new file mode 100644 index 0000000..7f2bfd9 --- /dev/null +++ b/test/ui-pages.test.ts @@ -0,0 +1,134 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +import { apps } from '../custom-server/src/ui-pages.ts'; +import { buildApp, createClient } from './helpers.ts'; + +let app: FastifyInstance; +let client: ReturnType; + +beforeAll(async () => { + app = await buildApp(); + client = createClient(app); +}); + +afterAll(() => app.close()); + +// Куки из ответа в виде заголовка Cookie для следующего запроса. +const cookieHeader = (headers: Record) => + [headers['set-cookie']] + .flat() + .filter(Boolean) + .map((cookie: string) => cookie.split(';')[0]) + .join('; '); + +const logIn = (prefix: string, username: string, password: string) => + client.send(`${prefix}/authenticate`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + payload: new URLSearchParams({ username, password }).toString(), + }); + +const courses = Object.entries(apps).map(([course, ui]) => ({ prefix: `/${course}`, ui })); + +// Уроки ищут эти элементы и тексты дословно. +const pages: [string, string[]][] = [ + ['/java-selenium', ['City Library', '

City Library

']], + ['/java-selenium/covers', ['class="figure"', '
Moby Dick
']], + ['/java-selenium/shelves', ['id="book-moby-dick"', 'id="shelf-reading"']], + ['/java-selenium/search', ['id="search"', 'id="result"']], + ['/java-selenium/return', ['id="return-book"']], + ['/java-selenium/preview', ['target="_blank">Open preview']], + ['/java-selenium/preview/moby-dick', ['

Moby Dick: preview

']], + ['/java-selenium/reader', ['name="chapter"']], + ['/java-selenium/reader/chapter', ['

Call me Ishmael.

']], + ['/python-selenium', ['Task Board', '

Task Board

', '>Sign in']], + ['/python-playwright', ['Departures', 'id="departures"', '>My trips']], +]; + +describe('страницы приложений', () => { + it.each(pages)('%s', async (url, fragments) => { + const reply = await client.send(url); + expect(reply.status).toBe(200); + for (const fragment of fragments) { + expect(reply.body).toContain(fragment); + } + expect(reply.body).not.toContain('{{prefix}}'); + }); + + it('стиль страниц отдаётся статикой', async () => { + expect((await client.send('/assets/ui-pages/style.css')).status).toBe(200); + }); +}); + +describe.each(courses)('вход и закрытый раздел $prefix', ({ prefix, ui }) => { + const { credentials, texts } = ui; + const secure = `${prefix}${ui.securePath}`; + + it('страница входа', async () => { + const reply = await client.send(`${prefix}/login`); + expect(reply.status).toBe(200); + for (const fragment of [ + `

${texts.loginHeading}

`, + 'form id="login"', + '', + 'id="password"', + 'class="primary" type="submit" data-test="login-button"', + ]) { + expect(reply.body).toContain(fragment); + } + }); + + it('без сессии закрытый раздел уводит на вход с сообщением', async () => { + const reply = await client.send(secure); + expect(reply.status).toBe(302); + expect(reply.headers.location).toBe(`${prefix}/login`); + + const login = await client.send(`${prefix}/login`, { headers: { cookie: cookieHeader(reply.headers) } }); + expect(login.body).toContain(`
${texts.mustLogIn}
`); + }); + + it('неверный пароль возвращает на вход', async () => { + const reply = await logIn(prefix, credentials.username, 'wrong'); + expect(reply.headers.location).toBe(`${prefix}/login`); + + const login = await client.send(`${prefix}/login`, { headers: { cookie: cookieHeader(reply.headers) } }); + expect(login.body).toContain(texts.invalid); + }); + + it('верная учётка открывает закрытый раздел, выход закрывает его', async () => { + const reply = await logIn(prefix, credentials.username, credentials.password); + expect(reply.status).toBe(302); + expect(reply.headers.location).toBe(secure); + + const cookie = cookieHeader(reply.headers); + const page = await client.send(secure, { headers: { cookie } }); + expect(page.status).toBe(200); + expect(page.body).toContain(`

${texts.secureHeading}

`); + expect(page.body).toContain(`
${texts.loggedIn}
`); + expect(page.body.split(`href="${prefix}/logout"`)).toHaveLength(2); + expect(page.body).toContain('id="menu-button"'); + + const logout = await client.send(`${prefix}/logout`, { headers: { cookie } }); + expect(logout.headers.location).toBe(`${prefix}/login`); + expect(String(logout.headers['set-cookie'])).toContain('ui-session=;'); + }); + + it('подделанная сессия не открывает закрытый раздел', async () => { + const reply = await client.send(secure, { headers: { cookie: `ui-session=${credentials.username}` } }); + expect(reply.status).toBe(302); + }); +}); + +describe('курсы не делят сессию', () => { + it('вход в одном курсе не открывает закрытый раздел другого', async () => { + const [first, second] = courses; + const reply = await logIn(first.prefix, first.ui.credentials.username, first.ui.credentials.password); + expect(String(reply.headers['set-cookie'])).toContain(`Path=${first.prefix}`); + + const page = await client.send(`${second.prefix}${second.ui.securePath}`, { + headers: { cookie: cookieHeader(reply.headers) }, + }); + expect(page.status).toBe(302); + }); +});