From 21ffd96c31b16f3c703c778b067939eebd68ba0c Mon Sep 17 00:00:00 2001 From: Nikolay Gagarinov Date: Mon, 19 Oct 2026 19:15:03 +0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(ui-testing):=20CONTENT-730=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BA=D1=83=D1=80=D1=81=D0=BE=D0=B2=20Selenium=20=D0=B8=20P?= =?UTF-8?q?laywright?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Курсы открывали example.com и the-internet.herokuapp.com. Заголовок h1 с example.com пропал, и примеры уроков падали. Префикс /ui-testing даёт свои страницы: вход и закрытый раздел с сессией, наведение, перетаскивание, клавиши, алерты, окна и фрейм. Локаторы и тексты держит test/ui-testing.test.ts. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 10 +- README.md | 20 ++- .../assets/ui-testing/drag_and_drop.html | 42 ++++++ custom-server/assets/ui-testing/hovers.html | 28 ++++ custom-server/assets/ui-testing/iframe.html | 17 +++ .../assets/ui-testing/iframe_content.html | 10 ++ custom-server/assets/ui-testing/index.html | 23 ++++ .../assets/ui-testing/javascript_alerts.html | 38 ++++++ .../assets/ui-testing/key_presses.html | 25 ++++ custom-server/assets/ui-testing/style.css | 19 +++ custom-server/assets/ui-testing/windows.html | 17 +++ .../assets/ui-testing/windows_new.html | 15 ++ custom-server/src/index.ts | 2 + custom-server/src/ui-testing.ts | 129 ++++++++++++++++++ test/ui-testing.test.ts | 112 +++++++++++++++ 15 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 custom-server/assets/ui-testing/drag_and_drop.html create mode 100644 custom-server/assets/ui-testing/hovers.html create mode 100644 custom-server/assets/ui-testing/iframe.html create mode 100644 custom-server/assets/ui-testing/iframe_content.html create mode 100644 custom-server/assets/ui-testing/index.html create mode 100644 custom-server/assets/ui-testing/javascript_alerts.html create mode 100644 custom-server/assets/ui-testing/key_presses.html create mode 100644 custom-server/assets/ui-testing/style.css create mode 100644 custom-server/assets/ui-testing/windows.html create mode 100644 custom-server/assets/ui-testing/windows_new.html create mode 100644 custom-server/src/ui-testing.ts create mode 100644 test/ui-testing.test.ts diff --git a/AGENTS.md b/AGENTS.md index 04bd24c..57636d6 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,14 @@ make deploy APP=http_example HOST=timeweb SKIP=caddy,cron **Значения полей только на латинице.** Сервер один на все локали курсов, и русский текст в ответе читался бы как ошибка у испанского студента. По-русски здесь пишутся комментарии в коде и спецификациях, не данные. +## Страницы для UI-тестов это тоже текст уроков + +Префикс `/ui-testing` обслуживают курсы Selenium и Playwright (java, python, js; ru и es). Уроки находят элементы по `id`, классам и `data-test` и сравнивают надписи дословно, поэтому переименование `#username`, `button[type='submit']`, `.figcaption h5` или текста флеш-сообщения ломает примеры в нескольких курсах сразу. Список страниц и локаторов лежит в README, его держит `test/ui-testing.test.ts`. + +- **Перетаскивание собрано на `mousedown` и `mouseup`.** `Actions.dragAndDrop` в Selenium эмулирует мышь, а события HTML5 `dragstart` и `drop` браузер на неё не шлёт, поэтому `draggable` пример молча не срабатывал бы. +- **Сессия это подписанная кука с путём `/ui-testing`.** Урок про куки сохраняет их после входа, открывает страницу в новом браузере, подкладывает куки и идёт в `/ui-testing/secure`. Неподписанное значение раздел не открывает. +- **Учётка `student` / `secret123` напечатана на странице входа и в уроках.** Её смена это правка курсов. + ## Набор данных не меняется `POST` и `DELETE` отвечают 201 и 204, но список задач остаётся прежним: создание diff --git a/README.md b/README.md index 0e9a591..b782f4a 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,24 @@ Обработчик `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 открывают учебные страницы под префиксом `/ui-testing`. Уроки ищут на них элементы и проверяют тексты дословно, поэтому локаторы и надписи этих страниц тоже контент курса. + +| Путь | Что на странице | +| --- | --- | +| `/ui-testing` | `h1` «Welcome to UI testing» и ссылки на примеры | +| `/ui-testing/login` | форма `#login` с полями `#username` и `#password`, учётка `student` / `secret123` | +| `/ui-testing/secure` | закрытый раздел, флеш `#flash`, меню `#menu-button` и ссылка на выход | +| `/ui-testing/hovers` | карточки `.figure`, подпись `.figcaption h5` видна при наведении | +| `/ui-testing/drag_and_drop` | колонки `#column-a` и `#column-b`, перетаскивание на событиях мыши | +| `/ui-testing/key_presses` | поле `#target`, результат в `#result` | +| `/ui-testing/javascript_alerts` | кнопки `jsAlert()`, `jsConfirm()` и `jsPrompt()` | +| `/ui-testing/windows` | ссылка «Click Here» открывает новое окно | +| `/ui-testing/iframe` | фрейм `editor-frame` с абзацем `#content` | + +Вход и закрытый раздел собирает `custom-server/src/ui-testing.ts`, остальные страницы лежат статикой в `custom-server/assets/ui-testing/`. + ## Prerequisites - [mise](https://mise.jdx.dev) (ставит Node, pnpm, Caddy и jactionlint по `mise.toml`) diff --git a/custom-server/assets/ui-testing/drag_and_drop.html b/custom-server/assets/ui-testing/drag_and_drop.html new file mode 100644 index 0000000..65a12d7 --- /dev/null +++ b/custom-server/assets/ui-testing/drag_and_drop.html @@ -0,0 +1,42 @@ + + + + + + Hexlet UI Testing + + + + UI testing examples +
+

Drag and Drop

+
+
A
+
B
+
+
+ + + + diff --git a/custom-server/assets/ui-testing/hovers.html b/custom-server/assets/ui-testing/hovers.html new file mode 100644 index 0000000..6323756 --- /dev/null +++ b/custom-server/assets/ui-testing/hovers.html @@ -0,0 +1,28 @@ + + + + + + Hexlet UI Testing + + + + UI testing examples +
+

Hovers

+

Hover over a user card to see the name. Double-click the name to select it.

+
1
name: user1
View profile
+
2
name: user2
View profile
+
3
name: user3
View profile
+

+
+ + + + diff --git a/custom-server/assets/ui-testing/iframe.html b/custom-server/assets/ui-testing/iframe.html new file mode 100644 index 0000000..e33ff6c --- /dev/null +++ b/custom-server/assets/ui-testing/iframe.html @@ -0,0 +1,17 @@ + + + + + + Hexlet UI Testing + + + + UI testing examples +
+

An iframe with an editor

+ +
+ + + diff --git a/custom-server/assets/ui-testing/iframe_content.html b/custom-server/assets/ui-testing/iframe_content.html new file mode 100644 index 0000000..ec46555 --- /dev/null +++ b/custom-server/assets/ui-testing/iframe_content.html @@ -0,0 +1,10 @@ + + + + + Editor + + +

Your content goes here.

+ + diff --git a/custom-server/assets/ui-testing/index.html b/custom-server/assets/ui-testing/index.html new file mode 100644 index 0000000..fe53c2f --- /dev/null +++ b/custom-server/assets/ui-testing/index.html @@ -0,0 +1,23 @@ + + + + + + Hexlet UI Testing + + + +

Welcome to UI testing

+

Available examples

+ + + + diff --git a/custom-server/assets/ui-testing/javascript_alerts.html b/custom-server/assets/ui-testing/javascript_alerts.html new file mode 100644 index 0000000..1ef1522 --- /dev/null +++ b/custom-server/assets/ui-testing/javascript_alerts.html @@ -0,0 +1,38 @@ + + + + + + Hexlet UI Testing + + + + UI testing examples +
+

JavaScript Alerts

+
    +
  • +
  • +
  • +
+

Result:

+

+
+ + + + diff --git a/custom-server/assets/ui-testing/key_presses.html b/custom-server/assets/ui-testing/key_presses.html new file mode 100644 index 0000000..7482675 --- /dev/null +++ b/custom-server/assets/ui-testing/key_presses.html @@ -0,0 +1,25 @@ + + + + + + Hexlet UI Testing + + + + UI testing examples +
+

Key Presses

+

Focus the field and press a key.

+ + +

+
+ + + + diff --git a/custom-server/assets/ui-testing/style.css b/custom-server/assets/ui-testing/style.css new file mode 100644 index 0000000..ee3c79f --- /dev/null +++ b/custom-server/assets/ui-testing/style.css @@ -0,0 +1,19 @@ +body { font: 18px/1.5 system-ui, sans-serif; margin: 40px auto; padding: 0 24px; max-width: 720px; color: #18212d; } +a { color: #315acc; } +footer { margin-top: 48px; font-size: 14px; color: #5c6675; } +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; position: relative; margin: 16px 16px 0 0; vertical-align: top; } +.avatar { width: 120px; height: 120px; display: grid; place-items: center; font-size: 40px; color: white; background: #6b7a90; border-radius: 8px; } +.figcaption { display: none; } +.figure:hover .figcaption { display: block; } +.figcaption h5 { margin: 8px 0 0; font-size: 16px; } +#columns { display: flex; gap: 24px; } +.column { width: 160px; height: 160px; display: grid; place-items: center; font-size: 40px; background: #e4e9f1; border: 2px solid #9ca6b4; border-radius: 8px; cursor: move; user-select: none; } +iframe { width: 100%; height: 200px; border: 1px solid #9ca6b4; border-radius: 6px; } diff --git a/custom-server/assets/ui-testing/windows.html b/custom-server/assets/ui-testing/windows.html new file mode 100644 index 0000000..ff272ec --- /dev/null +++ b/custom-server/assets/ui-testing/windows.html @@ -0,0 +1,17 @@ + + + + + + Hexlet UI Testing + + + + UI testing examples +
+

Opening a new window

+ Click Here +
+ + + diff --git a/custom-server/assets/ui-testing/windows_new.html b/custom-server/assets/ui-testing/windows_new.html new file mode 100644 index 0000000..69080a3 --- /dev/null +++ b/custom-server/assets/ui-testing/windows_new.html @@ -0,0 +1,15 @@ + + + + + + Hexlet UI Testing + + + +
+

New Window

+
+ + + diff --git a/custom-server/src/index.ts b/custom-server/src/index.ts index b54eed0..6a37286 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 setUpUiTesting from './ui-testing.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(setUpUiTesting); await setupDocs(app); diff --git a/custom-server/src/ui-testing.ts b/custom-server/src/ui-testing.ts new file mode 100644 index 0000000..e1f6f71 --- /dev/null +++ b/custom-server/src/ui-testing.ts @@ -0,0 +1,129 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +// Учебные страницы для курсов Selenium и Playwright. Уроки цитируют локаторы и +// тексты этих страниц дословно, так же как уроки HTTP цитируют ответы API. +// Вход и закрытый раздел собираются здесь, потому что им нужны сессия и +// флеш-сообщение. Остальные страницы лежат статикой в assets/ui-testing. + +export const credentials = { username: 'student', password: 'secret123' }; + +const prefix = '/ui-testing'; +const sessionCookie = 'ui-session'; +const flashCookie = 'ui-flash'; +const cookieOptions = { path: prefix, httpOnly: true, sameSite: 'lax' } as const; + +const staticPages = { + '': 'index.html', + '/hovers': 'hovers.html', + '/drag_and_drop': 'drag_and_drop.html', + '/key_presses': 'key_presses.html', + '/javascript_alerts': 'javascript_alerts.html', + '/windows': 'windows.html', + '/windows/new': 'windows_new.html', + '/iframe': 'iframe.html', + '/iframe/content': 'iframe_content.html', +}; + +const escape = (text: string) => + text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); + +const layout = (body: string) => ` + + + + + Hexlet UI Testing + + + + UI testing examples +${body} + + + +`; + +const flashBlock = (flash: string | undefined, kind: 'success' | 'error') => + flash ? `
${escape(flash)}
\n` : ''; + +const takeFlash = (req: FastifyRequest, res: FastifyReply) => { + const raw = req.cookies[flashCookie]; + if (!raw) return undefined; + res.clearCookie(flashCookie, cookieOptions); + const [kind, message] = raw.split(':', 2) as ['success' | 'error', string]; + return { kind, message }; +}; + +const redirectWithFlash = (res: FastifyReply, url: string, kind: 'success' | 'error', message: string) => + res.setCookie(flashCookie, `${kind}:${message}`, cookieOptions).redirect(url); + +const isLoggedIn = (req: FastifyRequest) => { + const raw = req.cookies[sessionCookie]; + if (!raw) return false; + const { valid, value } = req.unsignCookie(raw); + return valid && value === credentials.username; +}; + +export default async function setUpUiTesting(app: FastifyInstance) { + for (const [route, file] of Object.entries(staticPages)) { + app.get(`${prefix}${route}`, (_req, res) => res.sendFile(`ui-testing/${file}`)); + } + + app.get(`${prefix}/login`, (req, res) => { + const flash = takeFlash(req, res); + return res.type('text/html; charset=utf-8').send( + layout(`${flashBlock(flash?.message, flash?.kind ?? 'error')}
+

Login Page

+

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

+
+ + + + + +
+
`), + ); + }); + + app.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', 'Your username or password is invalid!'); + } + res.setCookie(sessionCookie, credentials.username, { ...cookieOptions, signed: true }); + return redirectWithFlash(res, `${prefix}/secure`, 'success', 'You logged into a secure area!'); + }); + + app.get(`${prefix}/secure`, (req, res) => { + if (!isLoggedIn(req)) { + return redirectWithFlash(res, `${prefix}/login`, 'error', 'You must log in to view the secure area!'); + } + const flash = takeFlash(req, res); + return res.type('text/html; charset=utf-8').send( + layout(`${flashBlock(flash?.message, flash?.kind ?? 'success')}
+ + +

Secure Area

+

Welcome to the secure area. Log out when you are done.

+ Logout +
+ `), + ); + }); + + app.get(`${prefix}/logout`, (_req, res) => { + res.clearCookie(sessionCookie, cookieOptions); + return redirectWithFlash(res, `${prefix}/login`, 'success', 'You logged out of the secure area!'); + }); +} diff --git a/test/ui-testing.test.ts b/test/ui-testing.test.ts new file mode 100644 index 0000000..a29508d --- /dev/null +++ b/test/ui-testing.test.ts @@ -0,0 +1,112 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +import { credentials } from '../custom-server/src/ui-testing.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 = (username: string, password: string) => + client.send('/ui-testing/authenticate', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + payload: new URLSearchParams({ username, password }).toString(), + }); + +// Уроки Selenium и Playwright ищут эти элементы и тексты дословно. +describe('страницы для UI-тестов', () => { + it.each([ + [ + '/ui-testing', + ['Hexlet UI Testing', '

Welcome to UI testing

', 'Form Authentication'], + ], + ['/ui-testing/hovers', ['class="figure"', '
name: user1
']], + ['/ui-testing/drag_and_drop', ['id="column-a"', 'id="column-b"']], + ['/ui-testing/key_presses', ['id="target"', 'id="result"']], + ['/ui-testing/javascript_alerts', ['onclick="jsAlert()"', 'onclick="jsConfirm()"', 'onclick="jsPrompt()"']], + ['/ui-testing/windows', ['target="_blank">Click Here']], + ['/ui-testing/windows/new', ['

New Window

']], + ['/ui-testing/iframe', ['name="editor-frame"']], + ['/ui-testing/iframe/content', ['id="content"']], + [ + '/ui-testing/login', + [ + '

Login Page

', + 'form id="login"', + 'id="username"', + 'id="password"', + 'type="submit" data-test="login-button"', + ], + ], + ])('%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); + } + }); + + it('стиль страниц отдаётся статикой', async () => { + expect((await client.send('/assets/ui-testing/style.css')).status).toBe(200); + }); +}); + +describe('вход в закрытый раздел', () => { + it('без сессии закрытый раздел уводит на вход с сообщением', async () => { + const reply = await client.send('/ui-testing/secure'); + expect(reply.status).toBe(302); + expect(reply.headers.location).toBe('/ui-testing/login'); + + const login = await client.send('/ui-testing/login', { headers: { cookie: cookieHeader(reply.headers) } }); + expect(login.body).toContain('
You must log in to view the secure area!
'); + }); + + it('неверный пароль возвращает на вход', async () => { + const reply = await logIn(credentials.username, 'wrong'); + expect(reply.status).toBe(302); + expect(reply.headers.location).toBe('/ui-testing/login'); + + const login = await client.send('/ui-testing/login', { headers: { cookie: cookieHeader(reply.headers) } }); + expect(login.body).toContain('Your username or password is invalid!'); + }); + + it('верная учётка открывает закрытый раздел, выход закрывает его', async () => { + const reply = await logIn(credentials.username, credentials.password); + expect(reply.status).toBe(302); + expect(reply.headers.location).toBe('/ui-testing/secure'); + + const cookie = cookieHeader(reply.headers); + const secure = await client.send('/ui-testing/secure', { headers: { cookie } }); + expect(secure.status).toBe(200); + expect(secure.body).toContain('

Secure Area

'); + expect(secure.body).toContain('
You logged into a secure area!
'); + expect(secure.body).toContain('href="/ui-testing/logout"'); + expect(secure.body).toContain('id="menu-button"'); + + const logout = await client.send('/ui-testing/logout', { headers: { cookie } }); + expect(logout.status).toBe(302); + expect(logout.headers.location).toBe('/ui-testing/login'); + expect(String(logout.headers['set-cookie'])).toContain('ui-session=;'); + }); + + it('подделанная сессия не открывает закрытый раздел', async () => { + const reply = await client.send('/ui-testing/secure', { headers: { cookie: 'ui-session=student' } }); + expect(reply.status).toBe(302); + }); +}); From 1e2c8969358d06ee6d17cf867856f3d092a3f514 Mon Sep 17 00:00:00 2001 From: Nikolay Gagarinov Date: Mon, 19 Oct 2026 19:48:54 +0500 Subject: [PATCH 2/3] =?UTF-8?q?refactor(ui-pages):=20CONTENT-730=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=81=D0=B8=D1=82=D1=8C=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20=D0=BD=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20=D0=BA=D1=83=D1=80=D1=81?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Префикс сервера это курс, как у http-api и postman. Страницы общие и регистрируются циклом по курсам: /java-selenium, /python-selenium, /python-playwright. Ссылки, путь куки и значение сессии у курса свои, и кука одного курса не открывает закрытый раздел другого. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 4 +- README.md | 24 ++-- .../drag_and_drop.html | 4 +- .../{ui-testing => ui-pages}/hovers.html | 10 +- .../{ui-testing => ui-pages}/iframe.html | 6 +- .../iframe_content.html | 0 custom-server/assets/ui-pages/index.html | 23 ++++ .../javascript_alerts.html | 4 +- .../{ui-testing => ui-pages}/key_presses.html | 4 +- .../assets/{ui-testing => ui-pages}/style.css | 0 .../{ui-testing => ui-pages}/windows.html | 6 +- .../{ui-testing => ui-pages}/windows_new.html | 2 +- custom-server/assets/ui-testing/index.html | 23 ---- custom-server/src/index.ts | 4 +- .../src/{ui-testing.ts => ui-pages.ts} | 87 ++++++++---- test/ui-pages.test.ts | 128 ++++++++++++++++++ test/ui-testing.test.ts | 112 --------------- 17 files changed, 246 insertions(+), 195 deletions(-) rename custom-server/assets/{ui-testing => ui-pages}/drag_and_drop.html (92%) rename custom-server/assets/{ui-testing => ui-pages}/hovers.html (70%) rename custom-server/assets/{ui-testing => ui-pages}/iframe.html (72%) rename custom-server/assets/{ui-testing => ui-pages}/iframe_content.html (100%) create mode 100644 custom-server/assets/ui-pages/index.html rename custom-server/assets/{ui-testing => ui-pages}/javascript_alerts.html (90%) rename custom-server/assets/{ui-testing => ui-pages}/key_presses.html (86%) rename custom-server/assets/{ui-testing => ui-pages}/style.css (100%) rename custom-server/assets/{ui-testing => ui-pages}/windows.html (66%) rename custom-server/assets/{ui-testing => ui-pages}/windows_new.html (84%) delete mode 100644 custom-server/assets/ui-testing/index.html rename custom-server/src/{ui-testing.ts => ui-pages.ts} (52%) create mode 100644 test/ui-pages.test.ts delete mode 100644 test/ui-testing.test.ts diff --git a/AGENTS.md b/AGENTS.md index 57636d6..8c53df2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -271,10 +271,10 @@ make deploy APP=http_example HOST=timeweb SKIP=caddy,cron ## Страницы для UI-тестов это тоже текст уроков -Префикс `/ui-testing` обслуживают курсы Selenium и Playwright (java, python, js; ru и es). Уроки находят элементы по `id`, классам и `data-test` и сравнивают надписи дословно, поэтому переименование `#username`, `button[type='submit']`, `.figcaption h5` или текста флеш-сообщения ломает примеры в нескольких курсах сразу. Список страниц и локаторов лежит в README, его держит `test/ui-testing.test.ts`. +Префиксы `/java-selenium`, `/python-selenium` и `/python-playwright` обслуживают курсы Selenium и Playwright, ru и es. Страницы у них общие, маршруты регистрируются циклом по `courses` в `custom-server/src/ui-pages.ts`. Уроки находят элементы по `id`, классам и `data-test` и сравнивают надписи дословно, поэтому переименование `#username`, `button[type='submit']`, `.figcaption h5` или текста флеш-сообщения ломает примеры в нескольких курсах сразу. Список страниц и локаторов лежит в README, его держит `test/ui-pages.test.ts`. - **Перетаскивание собрано на `mousedown` и `mouseup`.** `Actions.dragAndDrop` в Selenium эмулирует мышь, а события HTML5 `dragstart` и `drop` браузер на неё не шлёт, поэтому `draggable` пример молча не срабатывал бы. -- **Сессия это подписанная кука с путём `/ui-testing`.** Урок про куки сохраняет их после входа, открывает страницу в новом браузере, подкладывает куки и идёт в `/ui-testing/secure`. Неподписанное значение раздел не открывает. +- **Сессия это подписанная кука с путём курса, и курс записан в её значении.** Урок про куки сохраняет их после входа, открывает страницу в новом браузере, подкладывает куки руками и идёт в `/<курс>/secure`. Кука одного курса не открывает раздел другого, неподписанное значение не открывает ничего. - **Учётка `student` / `secret123` напечатана на странице входа и в уроках.** Её смена это правка курсов. ## Набор данных не меняется diff --git a/README.md b/README.md index b782f4a..0a24d66 100644 --- a/README.md +++ b/README.md @@ -21,21 +21,21 @@ ## Страницы для UI-тестов -Курсы Selenium и Playwright открывают учебные страницы под префиксом `/ui-testing`. Уроки ищут на них элементы и проверяют тексты дословно, поэтому локаторы и надписи этих страниц тоже контент курса. +Курсы Selenium и Playwright открывают учебные страницы под префиксом своего курса: `/java-selenium`, `/python-selenium`, `/python-playwright`. Страницы у всех одни, ссылки внутри и сессия у каждого курса свои. Уроки ищут на страницах элементы и проверяют тексты дословно, поэтому локаторы и надписи тоже контент курса. В таблице префикс курса обозначен `<курс>`. | Путь | Что на странице | | --- | --- | -| `/ui-testing` | `h1` «Welcome to UI testing» и ссылки на примеры | -| `/ui-testing/login` | форма `#login` с полями `#username` и `#password`, учётка `student` / `secret123` | -| `/ui-testing/secure` | закрытый раздел, флеш `#flash`, меню `#menu-button` и ссылка на выход | -| `/ui-testing/hovers` | карточки `.figure`, подпись `.figcaption h5` видна при наведении | -| `/ui-testing/drag_and_drop` | колонки `#column-a` и `#column-b`, перетаскивание на событиях мыши | -| `/ui-testing/key_presses` | поле `#target`, результат в `#result` | -| `/ui-testing/javascript_alerts` | кнопки `jsAlert()`, `jsConfirm()` и `jsPrompt()` | -| `/ui-testing/windows` | ссылка «Click Here» открывает новое окно | -| `/ui-testing/iframe` | фрейм `editor-frame` с абзацем `#content` | - -Вход и закрытый раздел собирает `custom-server/src/ui-testing.ts`, остальные страницы лежат статикой в `custom-server/assets/ui-testing/`. +| `/<курс>` | `h1` «Welcome to UI testing» и ссылки на примеры | +| `/<курс>/login` | форма `#login` с полями `#username` и `#password`, учётка `student` / `secret123` | +| `/<курс>/secure` | закрытый раздел, флеш `#flash`, меню `#menu-button` и ссылка на выход | +| `/<курс>/hovers` | карточки `.figure`, подпись `.figcaption h5` видна при наведении | +| `/<курс>/drag_and_drop` | колонки `#column-a` и `#column-b`, перетаскивание на событиях мыши | +| `/<курс>/key_presses` | поле `#target`, результат в `#result` | +| `/<курс>/javascript_alerts` | кнопки `jsAlert()`, `jsConfirm()` и `jsPrompt()` | +| `/<курс>/windows` | ссылка «Click Here» открывает новое окно | +| `/<курс>/iframe` | фрейм `editor-frame` с абзацем `#content` | + +Вход и закрытый раздел собирает `custom-server/src/ui-pages.ts`, остальные страницы лежат шаблонами в `custom-server/assets/ui-pages/`, префикс в них записан как `{{prefix}}`. Список курсов задаёт `courses` в том же модуле. ## Prerequisites diff --git a/custom-server/assets/ui-testing/drag_and_drop.html b/custom-server/assets/ui-pages/drag_and_drop.html similarity index 92% rename from custom-server/assets/ui-testing/drag_and_drop.html rename to custom-server/assets/ui-pages/drag_and_drop.html index 65a12d7..2669cc9 100644 --- a/custom-server/assets/ui-testing/drag_and_drop.html +++ b/custom-server/assets/ui-pages/drag_and_drop.html @@ -4,10 +4,10 @@ Hexlet UI Testing - + - UI testing examples + UI testing examples

Drag and Drop

diff --git a/custom-server/assets/ui-testing/hovers.html b/custom-server/assets/ui-pages/hovers.html similarity index 70% rename from custom-server/assets/ui-testing/hovers.html rename to custom-server/assets/ui-pages/hovers.html index 6323756..540ff41 100644 --- a/custom-server/assets/ui-testing/hovers.html +++ b/custom-server/assets/ui-pages/hovers.html @@ -4,16 +4,16 @@ Hexlet UI Testing - + - UI testing examples + UI testing examples

Hovers

Hover over a user card to see the name. Double-click the name to select it.

-
1
name: user1
View profile
-
2
name: user2
View profile
-
3
name: user3
View profile
+
1
name: user1
View profile
+
2
name: user2
View profile
+
3
name: user3
View profile

`), + `, + ), ); }); app.get(`${prefix}/logout`, (_req, res) => { res.clearCookie(sessionCookie, cookieOptions); - return redirectWithFlash(res, `${prefix}/login`, 'success', 'You logged out of the secure area!'); + return redirectWithFlash(res, prefix, '/login', 'success', 'You logged out of the secure area!'); }); +}; + +export default async function setUpUiPages(app: FastifyInstance) { + for (const course of courses) { + setUpCourse(app, course); + } } diff --git a/test/ui-pages.test.ts b/test/ui-pages.test.ts new file mode 100644 index 0000000..da70bca --- /dev/null +++ b/test/ui-pages.test.ts @@ -0,0 +1,128 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +import { courses, credentials } 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(), + }); + +// Уроки Selenium и Playwright ищут эти элементы и тексты дословно. +describe.each(courses.map((course) => `/${course}`))('страницы %s', (prefix) => { + it.each([ + ['', ['Hexlet UI Testing', '

Welcome to UI testing

', 'Form Authentication']], + ['/hovers', ['class="figure"', '
name: user1
']], + ['/drag_and_drop', ['id="column-a"', 'id="column-b"']], + ['/key_presses', ['id="target"', 'id="result"']], + ['/javascript_alerts', ['onclick="jsAlert()"', 'onclick="jsConfirm()"', 'onclick="jsPrompt()"']], + ['/windows', ['target="_blank">Click Here']], + ['/windows/new', ['

New Window

']], + ['/iframe', ['name="editor-frame"']], + ['/iframe/content', ['id="content"']], + [ + '/login', + [ + '

Login Page

', + 'form id="login"', + 'id="username"', + 'id="password"', + 'type="submit" data-test="login-button"', + ], + ], + ])('%s', async (url, fragments) => { + const reply = await client.send(`${prefix}${url}`); + expect(reply.status).toBe(200); + for (const fragment of fragments) { + expect(reply.body).toContain(fragment); + } + }); + + it('стиль страниц отдаётся статикой', async () => { + expect((await client.send('/assets/ui-pages/style.css')).status).toBe(200); + }); +}); + +describe.each(courses.map((course) => `/${course}`))('вход в закрытый раздел %s', (prefix) => { + it('без сессии закрытый раздел уводит на вход с сообщением', async () => { + const reply = await client.send(`${prefix}/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('
You must log in to view the secure area!
'); + }); + + it('неверный пароль возвращает на вход', async () => { + const reply = await logIn(prefix, credentials.username, 'wrong'); + 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('Your username or password is invalid!'); + }); + + it('верная учётка открывает закрытый раздел, выход закрывает его', async () => { + const reply = await logIn(prefix, credentials.username, credentials.password); + expect(reply.status).toBe(302); + expect(reply.headers.location).toBe(`${prefix}/secure`); + + const cookie = cookieHeader(reply.headers); + const secure = await client.send(`${prefix}/secure`, { headers: { cookie } }); + expect(secure.status).toBe(200); + expect(secure.body).toContain('

Secure Area

'); + expect(secure.body).toContain('
You logged into a secure area!
'); + expect(secure.body).toContain(`href="${prefix}/logout"`); + expect(secure.body).toContain('id="menu-button"'); + + const logout = await client.send(`${prefix}/logout`, { headers: { cookie } }); + expect(logout.status).toBe(302); + expect(logout.headers.location).toBe(`${prefix}/login`); + expect(String(logout.headers['set-cookie'])).toContain('ui-session=;'); + }); + + it('подделанная сессия не открывает закрытый раздел', async () => { + const reply = await client.send(`${prefix}/secure`, { headers: { cookie: 'ui-session=student' } }); + expect(reply.status).toBe(302); + }); +}); + +describe('курсы не делят сессию', () => { + it('вход в одном курсе не открывает закрытый раздел другого', async () => { + const [first, second] = courses; + const reply = await logIn(`/${first}`, credentials.username, credentials.password); + const cookie = cookieHeader(reply.headers); + expect(String(reply.headers['set-cookie'])).toContain(`Path=/${first}`); + const secure = await client.send(`/${second}/secure`, { headers: { cookie } }); + expect(secure.status).toBe(302); + }); + + it('ссылки страниц ведут внутрь своего курса', async () => { + for (const course of courses) { + const reply = await client.send(`/${course}`); + expect(reply.body).toContain(`href="/${course}/login"`); + expect(reply.body).not.toContain('{{prefix}}'); + } + }); +}); diff --git a/test/ui-testing.test.ts b/test/ui-testing.test.ts deleted file mode 100644 index a29508d..0000000 --- a/test/ui-testing.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { FastifyInstance } from 'fastify'; - -import { credentials } from '../custom-server/src/ui-testing.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 = (username: string, password: string) => - client.send('/ui-testing/authenticate', { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - payload: new URLSearchParams({ username, password }).toString(), - }); - -// Уроки Selenium и Playwright ищут эти элементы и тексты дословно. -describe('страницы для UI-тестов', () => { - it.each([ - [ - '/ui-testing', - ['Hexlet UI Testing', '

Welcome to UI testing

', 'Form Authentication'], - ], - ['/ui-testing/hovers', ['class="figure"', '
name: user1
']], - ['/ui-testing/drag_and_drop', ['id="column-a"', 'id="column-b"']], - ['/ui-testing/key_presses', ['id="target"', 'id="result"']], - ['/ui-testing/javascript_alerts', ['onclick="jsAlert()"', 'onclick="jsConfirm()"', 'onclick="jsPrompt()"']], - ['/ui-testing/windows', ['target="_blank">Click Here']], - ['/ui-testing/windows/new', ['

New Window

']], - ['/ui-testing/iframe', ['name="editor-frame"']], - ['/ui-testing/iframe/content', ['id="content"']], - [ - '/ui-testing/login', - [ - '

Login Page

', - 'form id="login"', - 'id="username"', - 'id="password"', - 'type="submit" data-test="login-button"', - ], - ], - ])('%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); - } - }); - - it('стиль страниц отдаётся статикой', async () => { - expect((await client.send('/assets/ui-testing/style.css')).status).toBe(200); - }); -}); - -describe('вход в закрытый раздел', () => { - it('без сессии закрытый раздел уводит на вход с сообщением', async () => { - const reply = await client.send('/ui-testing/secure'); - expect(reply.status).toBe(302); - expect(reply.headers.location).toBe('/ui-testing/login'); - - const login = await client.send('/ui-testing/login', { headers: { cookie: cookieHeader(reply.headers) } }); - expect(login.body).toContain('
You must log in to view the secure area!
'); - }); - - it('неверный пароль возвращает на вход', async () => { - const reply = await logIn(credentials.username, 'wrong'); - expect(reply.status).toBe(302); - expect(reply.headers.location).toBe('/ui-testing/login'); - - const login = await client.send('/ui-testing/login', { headers: { cookie: cookieHeader(reply.headers) } }); - expect(login.body).toContain('Your username or password is invalid!'); - }); - - it('верная учётка открывает закрытый раздел, выход закрывает его', async () => { - const reply = await logIn(credentials.username, credentials.password); - expect(reply.status).toBe(302); - expect(reply.headers.location).toBe('/ui-testing/secure'); - - const cookie = cookieHeader(reply.headers); - const secure = await client.send('/ui-testing/secure', { headers: { cookie } }); - expect(secure.status).toBe(200); - expect(secure.body).toContain('

Secure Area

'); - expect(secure.body).toContain('
You logged into a secure area!
'); - expect(secure.body).toContain('href="/ui-testing/logout"'); - expect(secure.body).toContain('id="menu-button"'); - - const logout = await client.send('/ui-testing/logout', { headers: { cookie } }); - expect(logout.status).toBe(302); - expect(logout.headers.location).toBe('/ui-testing/login'); - expect(String(logout.headers['set-cookie'])).toContain('ui-session=;'); - }); - - it('подделанная сессия не открывает закрытый раздел', async () => { - const reply = await client.send('/ui-testing/secure', { headers: { cookie: 'ui-session=student' } }); - expect(reply.status).toBe(302); - }); -}); From ad4620f7d6ba5c40b6247308779b72cc6e4f13f8 Mon Sep 17 00:00:00 2001 From: Nikolay Gagarinov Date: Mon, 19 Oct 2026 20:01:13 +0500 Subject: [PATCH 3/3] =?UTF-8?q?feat(ui-pages):=20CONTENT-730=20=D1=81?= =?UTF-8?q?=D0=B2=D0=BE=D1=91=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=83=20=D0=BA=D0=B0=D0=B6=D0=B4=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BA=D1=83=D1=80=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У страниц появилась предметная область, и каждая страница выросла из её сценария. У java-selenium библиотека City Library: обложки, полки, поиск, возврат книги, превью и читалка. У python-selenium трекер Task Board со списком задач и меню. У python-playwright табло Departures, которое печатает время в часовом поясе браузера и складывает строки на узком экране. Общими остались вход, сессия и флеш-сообщение. Выхода в меню нет, чтобы ссылка на /logout на странице была одна. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 10 +- README.md | 43 ++-- .../assets/ui-pages/drag_and_drop.html | 42 ---- custom-server/assets/ui-pages/hovers.html | 28 --- custom-server/assets/ui-pages/iframe.html | 17 -- .../assets/ui-pages/iframe_content.html | 10 - custom-server/assets/ui-pages/index.html | 23 -- .../assets/ui-pages/java-selenium/covers.html | 15 ++ .../assets/ui-pages/java-selenium/index.html | 13 + .../ui-pages/java-selenium/preview.html | 5 + .../java-selenium/preview_moby_dick.html | 4 + .../assets/ui-pages/java-selenium/reader.html | 5 + .../java-selenium/reader_chapter.html | 11 + .../assets/ui-pages/java-selenium/return.html | 12 + .../assets/ui-pages/java-selenium/search.html | 30 +++ .../ui-pages/java-selenium/shelves.html | 36 +++ .../assets/ui-pages/javascript_alerts.html | 38 --- .../assets/ui-pages/key_presses.html | 25 -- .../ui-pages/python-playwright/index.html | 23 ++ .../ui-pages/python-selenium/index.html | 5 + custom-server/assets/ui-pages/style.css | 23 +- custom-server/assets/ui-pages/windows.html | 17 -- .../assets/ui-pages/windows_new.html | 15 -- custom-server/src/ui-pages.ts | 222 +++++++++++++----- test/ui-pages.test.ts | 108 +++++---- 25 files changed, 428 insertions(+), 352 deletions(-) delete mode 100644 custom-server/assets/ui-pages/drag_and_drop.html delete mode 100644 custom-server/assets/ui-pages/hovers.html delete mode 100644 custom-server/assets/ui-pages/iframe.html delete mode 100644 custom-server/assets/ui-pages/iframe_content.html delete mode 100644 custom-server/assets/ui-pages/index.html create mode 100644 custom-server/assets/ui-pages/java-selenium/covers.html create mode 100644 custom-server/assets/ui-pages/java-selenium/index.html create mode 100644 custom-server/assets/ui-pages/java-selenium/preview.html create mode 100644 custom-server/assets/ui-pages/java-selenium/preview_moby_dick.html create mode 100644 custom-server/assets/ui-pages/java-selenium/reader.html create mode 100644 custom-server/assets/ui-pages/java-selenium/reader_chapter.html create mode 100644 custom-server/assets/ui-pages/java-selenium/return.html create mode 100644 custom-server/assets/ui-pages/java-selenium/search.html create mode 100644 custom-server/assets/ui-pages/java-selenium/shelves.html delete mode 100644 custom-server/assets/ui-pages/javascript_alerts.html delete mode 100644 custom-server/assets/ui-pages/key_presses.html create mode 100644 custom-server/assets/ui-pages/python-playwright/index.html create mode 100644 custom-server/assets/ui-pages/python-selenium/index.html delete mode 100644 custom-server/assets/ui-pages/windows.html delete mode 100644 custom-server/assets/ui-pages/windows_new.html diff --git a/AGENTS.md b/AGENTS.md index 8c53df2..14c9572 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -269,13 +269,15 @@ make deploy APP=http_example HOST=timeweb SKIP=caddy,cron **Значения полей только на латинице.** Сервер один на все локали курсов, и русский текст в ответе читался бы как ошибка у испанского студента. По-русски здесь пишутся комментарии в коде и спецификациях, не данные. -## Страницы для UI-тестов это тоже текст уроков +## Учебные приложения для UI-тестов это тоже текст уроков -Префиксы `/java-selenium`, `/python-selenium` и `/python-playwright` обслуживают курсы Selenium и Playwright, ru и es. Страницы у них общие, маршруты регистрируются циклом по `courses` в `custom-server/src/ui-pages.ts`. Уроки находят элементы по `id`, классам и `data-test` и сравнивают надписи дословно, поэтому переименование `#username`, `button[type='submit']`, `.figcaption h5` или текста флеш-сообщения ломает примеры в нескольких курсах сразу. Список страниц и локаторов лежит в README, его держит `test/ui-pages.test.ts`. +Префиксы `/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` пример молча не срабатывал бы. -- **Сессия это подписанная кука с путём курса, и курс записан в её значении.** Урок про куки сохраняет их после входа, открывает страницу в новом браузере, подкладывает куки руками и идёт в `/<курс>/secure`. Кука одного курса не открывает раздел другого, неподписанное значение не открывает ничего. -- **Учётка `student` / `secret123` напечатана на странице входа и в уроках.** Её смена это правка курсов. +- **Сессия это подписанная кука с путём курса, и курс записан в её значении.** Урок про куки сохраняет их после входа, открывает страницу в новом браузере, подкладывает куки руками и идёт в закрытый раздел. Кука одного курса не открывает раздел другого, неподписанное значение не открывает ничего. +- **Учётки напечатаны на страницах входа и в уроках.** Их смена это правка курсов. ## Набор данных не меняется diff --git a/README.md b/README.md index 0a24d66..bc7469d 100644 --- a/README.md +++ b/README.md @@ -19,23 +19,38 @@ Обработчик `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-тестов +## Учебные приложения для UI-тестов -Курсы Selenium и Playwright открывают учебные страницы под префиксом своего курса: `/java-selenium`, `/python-selenium`, `/python-playwright`. Страницы у всех одни, ссылки внутри и сессия у каждого курса свои. Уроки ищут на страницах элементы и проверяют тексты дословно, поэтому локаторы и надписи тоже контент курса. В таблице префикс курса обозначен `<курс>`. +Курсы 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.** Курс настраивает контекст браузера, и табло на эти настройки отвечает. + +| Путь | Сценарий | | --- | --- | -| `/<курс>` | `h1` «Welcome to UI testing» и ссылки на примеры | -| `/<курс>/login` | форма `#login` с полями `#username` и `#password`, учётка `student` / `secret123` | -| `/<курс>/secure` | закрытый раздел, флеш `#flash`, меню `#menu-button` и ссылка на выход | -| `/<курс>/hovers` | карточки `.figure`, подпись `.figcaption h5` видна при наведении | -| `/<курс>/drag_and_drop` | колонки `#column-a` и `#column-b`, перетаскивание на событиях мыши | -| `/<курс>/key_presses` | поле `#target`, результат в `#result` | -| `/<курс>/javascript_alerts` | кнопки `jsAlert()`, `jsConfirm()` и `jsPrompt()` | -| `/<курс>/windows` | ссылка «Click Here» открывает новое окно | -| `/<курс>/iframe` | фрейм `editor-frame` с абзацем `#content` | - -Вход и закрытый раздел собирает `custom-server/src/ui-pages.ts`, остальные страницы лежат шаблонами в `custom-server/assets/ui-pages/`, префикс в них записан как `{{prefix}}`. Список курсов задаёт `courses` в том же модуле. +| `/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 diff --git a/custom-server/assets/ui-pages/drag_and_drop.html b/custom-server/assets/ui-pages/drag_and_drop.html deleted file mode 100644 index 2669cc9..0000000 --- a/custom-server/assets/ui-pages/drag_and_drop.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - Hexlet UI Testing - - - - UI testing examples -
-

Drag and Drop

-
-
A
-
B
-
-
- - - - diff --git a/custom-server/assets/ui-pages/hovers.html b/custom-server/assets/ui-pages/hovers.html deleted file mode 100644 index 540ff41..0000000 --- a/custom-server/assets/ui-pages/hovers.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - Hexlet UI Testing - - - - UI testing examples -
-

Hovers

-

Hover over a user card to see the name. Double-click the name to select it.

-
1
name: user1
View profile
-
2
name: user2
View profile
-
3
name: user3
View profile
-

-
- - - - diff --git a/custom-server/assets/ui-pages/iframe.html b/custom-server/assets/ui-pages/iframe.html deleted file mode 100644 index d37073f..0000000 --- a/custom-server/assets/ui-pages/iframe.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - Hexlet UI Testing - - - - UI testing examples -
-

An iframe with an editor

- -
- - - diff --git a/custom-server/assets/ui-pages/iframe_content.html b/custom-server/assets/ui-pages/iframe_content.html deleted file mode 100644 index ec46555..0000000 --- a/custom-server/assets/ui-pages/iframe_content.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Editor - - -

Your content goes here.

- - diff --git a/custom-server/assets/ui-pages/index.html b/custom-server/assets/ui-pages/index.html deleted file mode 100644 index b80e7a1..0000000 --- a/custom-server/assets/ui-pages/index.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - Hexlet UI Testing - - - -

Welcome to UI testing

-

Available examples

- - - - 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

+ + +
    +
  • Moby Dick, Herman Melville
  • +
  • Treasure Island, Robert Louis Stevenson
  • +
  • Pride and Prejudice, Jane Austen
  • +
  • Frankenstein, Mary Shelley
  • +
+

+
+ 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/javascript_alerts.html b/custom-server/assets/ui-pages/javascript_alerts.html deleted file mode 100644 index 290244e..0000000 --- a/custom-server/assets/ui-pages/javascript_alerts.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - Hexlet UI Testing - - - - UI testing examples -
-

JavaScript Alerts

-
    -
  • -
  • -
  • -
-

Result:

-

-
- - - - diff --git a/custom-server/assets/ui-pages/key_presses.html b/custom-server/assets/ui-pages/key_presses.html deleted file mode 100644 index 12fde09..0000000 --- a/custom-server/assets/ui-pages/key_presses.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - Hexlet UI Testing - - - - UI testing examples -
-

Key Presses

-

Focus the field and press a key.

- - -

-
- - - - 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 index ee3c79f..c453d6b 100644 --- a/custom-server/assets/ui-pages/style.css +++ b/custom-server/assets/ui-pages/style.css @@ -1,6 +1,6 @@ -body { font: 18px/1.5 system-ui, sans-serif; margin: 40px auto; padding: 0 24px; max-width: 720px; color: #18212d; } +body { font: 18px/1.5 system-ui, sans-serif; margin: 40px auto; padding: 0 24px; max-width: 760px; color: #18212d; } a { color: #315acc; } -footer { margin-top: 48px; font-size: 14px; color: #5c6675; } +.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; } @@ -9,11 +9,22 @@ button, .button { display: inline-block; margin-top: 16px; padding: 10px 24px; f .flash.error { background: #fbe1e1; } #menu { display: flex; gap: 16px; margin-top: 8px; } #menu[hidden] { display: none; } -.figure { display: inline-block; position: relative; margin: 16px 16px 0 0; vertical-align: top; } -.avatar { width: 120px; height: 120px; display: grid; place-items: center; font-size: 40px; color: white; background: #6b7a90; border-radius: 8px; } +.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; } -#columns { display: flex; gap: 24px; } -.column { width: 160px; height: 160px; display: grid; place-items: center; font-size: 40px; background: #e4e9f1; border: 2px solid #9ca6b4; border-radius: 8px; cursor: move; user-select: none; } +.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/assets/ui-pages/windows.html b/custom-server/assets/ui-pages/windows.html deleted file mode 100644 index e3cd81c..0000000 --- a/custom-server/assets/ui-pages/windows.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - Hexlet UI Testing - - - - UI testing examples -
-

Opening a new window

- Click Here -
- - - diff --git a/custom-server/assets/ui-pages/windows_new.html b/custom-server/assets/ui-pages/windows_new.html deleted file mode 100644 index 06f90d9..0000000 --- a/custom-server/assets/ui-pages/windows_new.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - Hexlet UI Testing - - - -
-

New Window

-
- - - diff --git a/custom-server/src/ui-pages.ts b/custom-server/src/ui-pages.ts index 1ce423f..d1ffd34 100644 --- a/custom-server/src/ui-pages.ts +++ b/custom-server/src/ui-pages.ts @@ -2,59 +2,147 @@ 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}}. +// Учебные приложения для курсов Selenium и Playwright. Префикс это курс, как у +// остальных приложений сервера, и у каждого курса своя предметная область: +// страницы появляются из её сценариев, а не из списка элементов для тренировки. +// Уроки цитируют локаторы и тексты этих страниц дословно, так же как уроки HTTP +// цитируют ответы API. +// +// Общее у приложений только устройство: вёрстка, вход, сессия и флеш-сообщение +// собираются здесь. Страницы лежат фрагментами в assets/ui-pages/<курс>/, +// префикс курса в них записан как {{prefix}}. -export const credentials = { username: 'student', password: 'secret123' }; +interface Page { + file: string; + // Страница без общей вёрстки, например содержимое фрейма. + bare?: boolean; +} -export const courses = ['java-selenium', 'python-selenium', 'python-playwright']; +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: `
    +
  • Moby Dick, due in 7 days
  • +
  • Treasure Island, due in 12 days
  • +
`, + 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: `
    +
  • Write the first test
  • +
  • Add an explicit wait
  • +
  • Move locators to a page object
  • +
`, + 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: `
    +
  • SK 204 to Lisbon, seat 14A
  • +
`, + 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 staticPages = { - '': 'index.html', - '/hovers': 'hovers.html', - '/drag_and_drop': 'drag_and_drop.html', - '/key_presses': 'key_presses.html', - '/javascript_alerts': 'javascript_alerts.html', - '/windows': 'windows.html', - '/windows/new': 'windows_new.html', - '/iframe': 'iframe.html', - '/iframe/content': 'iframe_content.html', -}; +const assetsDir = path.join(import.meta.dirname, '../assets/ui-pages'); const escape = (text: string) => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); -const assetsDir = path.join(import.meta.dirname, '../assets/ui-pages'); -const templates = Object.fromEntries( - Object.values(staticPages).map((file) => [file, readFileSync(path.join(assetsDir, file), 'utf8')]), -); - -const layout = (prefix: string, body: string) => ` +const layout = (app: UiApp, prefix: string, body: string) => ` - Hexlet UI Testing + ${app.title} - UI testing examples + ${app.title} ${body} - `; -const flashBlock = (flash: string | undefined, kind: 'success' | 'error') => - flash ? `
${escape(flash)}
\n` : ''; +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]; @@ -74,71 +162,81 @@ const redirectWithFlash = ( // В значении сессии стоит курс: урок про куки подкладывает их руками, и кука // одного курса не должна открывать закрытый раздел другого. -const sessionValue = (prefix: string) => `${credentials.username}@${prefix}`; +const sessionValue = (app: UiApp, prefix: string) => `${app.credentials.username}@${prefix}`; -const isLoggedIn = (req: FastifyRequest, prefix: string) => { +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(prefix); + return valid && value === sessionValue(app, prefix); }; -const setUpCourse = (app: FastifyInstance, course: string) => { +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, file] of Object.entries(staticPages)) { - const html = templates[file].replaceAll('{{prefix}}', prefix); - app.get(`${prefix}${route}`, (_req, res) => res.type('text/html; charset=utf-8').send(html)); + 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)); } - app.get(`${prefix}/login`, (req, res) => { + server.get(`${prefix}/login`, (req, res) => { const flash = takeFlash(req, res, prefix); - return res.type('text/html; charset=utf-8').send( + return html( + res, layout( + app, prefix, - `${flashBlock(flash?.message, flash?.kind ?? 'error')}
-

Login Page

+ `${flashBlock(flash)}
+

${texts.loginHeading}

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

- +
-
`, + `, ), ); }); - app.post(`${prefix}/authenticate`, (req, res) => { + 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', 'Your username or password is invalid!'); + return redirectWithFlash(res, prefix, '/login', 'error', texts.invalid); } - res.setCookie(sessionCookie, sessionValue(prefix), { ...cookieOptions, signed: true }); - return redirectWithFlash(res, prefix, '/secure', 'success', 'You logged into a secure area!'); + res.setCookie(sessionCookie, sessionValue(app, prefix), { ...cookieOptions, signed: true }); + return redirectWithFlash(res, prefix, app.securePath, 'success', texts.loggedIn); }); - app.get(`${prefix}/secure`, (req, res) => { - if (!isLoggedIn(req, prefix)) { - return redirectWithFlash(res, prefix, '/login', 'error', 'You must log in to view the secure area!'); + 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); - return res.type('text/html; charset=utf-8').send( + const menu = app.menu + .map((item) => ` ${item.label}`) + .join('\n'); + return html( + res, layout( + app, prefix, - `${flashBlock(flash?.message, flash?.kind ?? 'success')}
+ `${flashBlock(flash)}
-

Secure Area

-

Welcome to the secure area. Log out when you are done.

+

${texts.secureHeading}

+ ${app.secureBody} Logout -
+