docs(web-modules): ARCHITECTURE, CLAUDE.md, README, e2e под модули

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 15:01:05 +04:00
parent 77ff12d54d
commit e2c925bff2
14 changed files with 167 additions and 63 deletions

View File

@ -89,25 +89,32 @@
общие части (`web/src/ui/` — компоненты дизайн-системы AURA, `web/src/styles/tokens.css` — токены и единственное место с общие части (`web/src/ui/` — компоненты дизайн-системы AURA, `web/src/styles/tokens.css` — токены и единственное место с
литералами цвета, `web/src/api/` — клиент HTTP, `web/src/auth/`, `web/src/projects/` — проект в адресе `?project=<метка>`, литералами цвета, `web/src/api/` — клиент HTTP, `web/src/auth/`, `web/src/projects/` — проект в адресе `?project=<метка>`,
выпадающий список, окно «Новый проект») и модули `web/src/modules/<имя>/`. Контракт веб-модуля — `HubModule` в выпадающий список, окно «Новый проект») и модули `web/src/modules/<имя>/`. Контракт веб-модуля — `HubModule` в
`web/src/modules/types.ts` (`pages`, `nav`, `Provider`, `SidebarStatus`, `TopbarNotice`, `useOverlay`, `useOnProjectCreated`); `web/src/modules/types.ts` (`name`, `title`, `pages`, `nav`, `Provider`, `status`, `Summary`, `useOverlay`, `projectFields`);
список модулей — константа `MODULES` в `web/src/modules/index.ts` (хуки вызываются в постоянном порядке). список модулей — константа `MODULES` в `web/src/modules/index.ts` (хуки вызываются в постоянном порядке).
Состояние модуля — данные, а не компонент: `status` (`ModuleStatus`) описывает агента (`agent`), охват (`scope`: `all`/`project`), свежесть
(`freshness(project)`), действие обновления (`refresh`: подпись, `start`, `isOwn`) и перечитывание после пакета (`invalidate`). Рисует и ведёт
его ядро: трекер пакетов и агентов — `web/src/shell/activity/` (`ActivityProvider`: подхват идущего пакета и `GET /agents` раз в 60 с,
опрос пакета каждые 3 с, `useModuleActivity(module)`, тексты `events.ts`/`agentText.ts`), блок состояния панели — `ModuleStatusBlock`, а модуль
даёт только `Summary` — карточку на «Обзоре» (`OverviewPage`: карточки модулей и «Последние пакеты проекта»). Адреса: «Обзор» — `/app/`,
страницы модуля — `/app/<модуль>/…` (`/app/jira/board`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`,
`/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`.
Модуль `wiki` (`web/src/modules/wiki/index.tsx`): страница «Вики» (`/app/wiki`): дерево (`Tree`) с поиском (`Search`), страница Модуль `wiki` (`web/src/modules/wiki/index.tsx`): страница «Вики» (`/app/wiki`): дерево (`Tree`) с поиском (`Search`), страница
(`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:` (`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:`
скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`), скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`),
плашка синхронизации в боковой панели (`SyncStatus`); поле «Корень вики» в окне «Новый проект» — через `projectFields`. сводка для «Обзора» (`Summary`) и `status` для ядра; поле «Корень вики» в окне «Новый проект» — через `projectFields`.
Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Доска связей» (`/app/board`), «Задачи» (`/app/issues`), «Лента» Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Доска связей» (`/app/jira/board`), «Задачи» (`/app/jira/issues`), «Лента»
(`/app/feed`), карточка `/app/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует (`/app/jira/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует
страницу по `location.state.background`). Внутри: `board/` (чистые `model`, `analysis`, `layout`, `storage.ts` — ключи страницу по `location.state.background`). Внутри: `board/` (чистые `model`, `analysis`, `layout`, `storage.ts` — ключи
`localStorage` прежней страницы, у проекта AWPCA прежние, у остальных с суффиксом `@<метка>`; отрисовка `components/`; `localStorage` прежней страницы, у проекта AWPCA прежние, у остальных с суффиксом `@<метка>`; отрисовка `components/`;
golden-эталоны `__fixtures__/`), `issues/` (список, карточка, `wiki/` — разбор вики-разметки, `attachments/` — список, golden-эталоны `__fixtures__/`), `issues/` (список, карточка, `wiki/` — разбор вики-разметки, `attachments/` — список,
плашки и скачивание: `POST /attachments/{id}/fetch`, опрос `GET /attachments/{id}`, затем `api.blob` на `/content`), плашки и скачивание: `POST /attachments/{id}/fetch`, опрос `GET /attachments/{id}`, затем `api.blob` на `/content`),
`feed/` (период, виды, группировка событий, `EventList`, общий с блоком «История» карточки), `refresh/` (кнопка и плашка `feed/` (период, виды, группировка событий, `EventList`, общий с блоком «История» карточки), `RefreshButton.tsx` (кнопка «Обновить из Jira» в шапках страниц), `Summary`/`status` для ядра, `api/queries.ts`.
обновления данных из Jira в боковой панели, отслеживание пакета: `RefreshProvider`, `useJiraRefresh`), `api/queries.ts`.
Собирается `make web` в `src/sdlc_hub/web/app/`; `core/api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие Собирается `make web` в `src/sdlc_hub/web/app/`; `core/api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие
`/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/board`. Данные — только через HTTP API с `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/` («Обзор»). Данные — только через HTTP API с
клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, перевод в модель доски — в `board/model`. клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, перевод в модель доски — в `board/model`.
Цепочки зависимостей (Python): Цепочки зависимостей (Python):

View File

@ -18,7 +18,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА:
- `src/sdlc_hub/modules/wiki/` — модуль wiki: `schema`, `settings`, `paths`, `projects` (поле `wiki_root`), `commands` (`wiki_sync`, `wiki_fetch` и хуки очереди), `convert` (HTML → Markdown), `stage`, `sync`, `search` (FTS5), `attachments`, `tick`, `api`, `mcp`. Входная точка — `MODULE` в `modules/wiki/__init__.py`. - `src/sdlc_hub/modules/wiki/` — модуль wiki: `schema`, `settings`, `paths`, `projects` (поле `wiki_root`), `commands` (`wiki_sync`, `wiki_fetch` и хуки очереди), `convert` (HTML → Markdown), `stage`, `sync`, `search` (FTS5), `attachments`, `tick`, `api`, `mcp`. Входная точка — `MODULE` в `modules/wiki/__init__.py`.
- Граница: ядро не импортирует модули (кроме ленивого импорта в `registry.py` и шагах миграций `db.py`), типы команд и термины модулей (jira, wiki, eva) живут в модуле, новые имена в ядре — без названий модулей; проверяют `test_core_imports`, `test_core_has_no_jira_terms`, `test_core_has_no_module_terms` в `tests/test_registry.py`. Меток-исключений нет. - Граница: ядро не импортирует модули (кроме ленивого импорта в `registry.py` и шагах миграций `db.py`), типы команд и термины модулей (jira, wiki, eva) живут в модуле, новые имена в ядре — без названий модулей; проверяют `test_core_imports`, `test_core_has_no_jira_terms`, `test_core_has_no_module_terms` в `tests/test_registry.py`. Меток-исключений нет.
- Цепочка ядра: `db` ← `settings`, `projects` ← `queue` ← `api` ← `cli`; `files` ← `queue`, `api`; реле `relay` — поток в `serve`; `backup` вызывается из `api` и из `db`. Модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core`. - Цепочка ядра: `db` ← `settings`, `projects` ← `queue` ← `api` ← `cli`; `files` ← `queue`, `api`; реле `relay` — поток в `serve`; `backup` вызывается из `api` и из `db`. Модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core`.
- Веб: оболочка и общее — `web/src/{shell,ui,api,projects,auth,styles}`, модули — `web/src/modules/<имя>/` (контракт `HubModule` в `modules/types.ts`, список — `modules/index.ts`); модуль jira — страницы доски, задач и ленты; модуль wiki (`web/src/modules/wiki/`) — страница «Вики» (`/app/wiki?project=<метка>`). - Веб: оболочка и общее — `web/src/{shell,ui,api,projects,auth,styles}`, модули — `web/src/modules/<имя>/` (контракт `HubModule` в `modules/types.ts`, список — `modules/index.ts`); состояние модулей (агент, свежесть, обновление) ведёт ядро в `web/src/shell/activity/`, модуль даёт `status` и `Summary` для «Обзора»; модуль jira — страницы доски, задач и ленты; модуль wiki (`web/src/modules/wiki/`) — страница «Вики» (`/app/wiki?project=<метка>`).
- Подробная карта модулей и границ — `ARCHITECTURE.md`. - Подробная карта модулей и границ — `ARCHITECTURE.md`.
## Команды ## Команды
@ -26,7 +26,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА:
- `uv sync` — установка окружения. - `uv sync` — установка окружения.
- `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. - `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
- `uv run sdlc-hub serve [--host] [--port]` — локальный запуск хаба. - `uv run sdlc-hub serve [--host] [--port]` — локальный запуск хаба.
- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board?project=<метка>`, задачи — `/app/issues?project=<метка>`, лента изменений — `/app/feed?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску. - Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, доска — `/app/jira/board?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira.
- `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур). - `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур).
- `uv run sdlc-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run sdlc-hub mcp <модуль> [--project <метка>]` — MCP модуля по stdio (модуль обязателен: `jira` — сервер `sdlc-jira`, `wiki` — сервер `sdlc-wiki`; с привязкой сессии к проекту); `uv run sdlc-hub tokens` — показать токены (клиентский и по агенту на модуль). - `uv run sdlc-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run sdlc-hub mcp <модуль> [--project <метка>]` — MCP модуля по stdio (модуль обязателен: `jira` — сервер `sdlc-jira`, `wiki` — сервер `sdlc-wiki`; с привязкой сессии к проекту); `uv run sdlc-hub tokens` — показать токены (клиентский и по агенту на модуль).
- `uv run sdlc-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`). - `uv run sdlc-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`).

View File

@ -217,7 +217,7 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py
| `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `changes` (новые сверху) | | `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `changes` (новые сверху) |
| `GET /changes` | клиент | лента изменений: `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `project` (только свои задачи), `key`, `kinds`, `limit` (до 2000) → `{since, until, truncated, changes}` | | `GET /changes` | клиент | лента изменений: `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `project` (только свои задачи), `key`, `kinds`, `limit` (до 2000) → `{since, until, truncated, changes}` |
| `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `fact_status`/`work_kind`; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон | | `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `fact_status`/`work_kind`; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон |
| `GET /`, `GET /links` | — | перенаправление на `/app/board` | | `GET /`, `GET /links` | — | перенаправление на `/app/` («Обзор») |
| `PUT /issues/{key}/note` | клиент | заметки (частично) | | `PUT /issues/{key}/note` | клиент | заметки (частично) |
| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта | | `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта |
| `POST /batches/{id}/cancel` | клиент | отмена пакета | | `POST /batches/{id}/cancel` | клиент | отмена пакета |
@ -234,8 +234,8 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=
## Веб-приложение ## Веб-приложение
Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board?project=<метка>`, задачи `/app/issues?project=<метка>`, лента изменений `/app/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), доска `/app/jira/board?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/jira/feed?project=<метка>`, вики `/app/wiki?project=<метка>` (период и виды событий, история — и в карточке задачи), карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и
`/links` перенаправляют на доску. Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере; `/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/issues`, `/app/feed` ведут на страницы модуля Jira. Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере;
`localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini: `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini:
``` ```

View File

@ -154,3 +154,6 @@ type HubModule = {
| 50 | 24.10.2026 | Task 7, fix T7.I1: обёртка таблицы «Задач» при высоте окна ≥ 700 px получает `max-height: calc(100vh - 220px)` и `overflow-y:auto`, шапка липнет к обёртке; на низких окнах ограничения нет — шапка уходит со страницей | при `overflow-x:auto` обёртка — скроллер, и `position:sticky` шапки внутри неё без собственной вертикальной прокрутки не работает (решение 47 ошибочно считало, что шапка липнет к панели); на длинных списках шапка должна оставаться на виду; цена — вложенная вертикальная прокрутка (на телефонах высотой < 700 px её нет) | снять sticky/z-index и принять уходящую шапку | | 50 | 24.10.2026 | Task 7, fix T7.I1: обёртка таблицы «Задач» при высоте окна ≥ 700 px получает `max-height: calc(100vh - 220px)` и `overflow-y:auto`, шапка липнет к обёртке; на низких окнах ограничения нет — шапка уходит со страницей | при `overflow-x:auto` обёртка — скроллер, и `position:sticky` шапки внутри неё без собственной вертикальной прокрутки не работает (решение 47 ошибочно считало, что шапка липнет к панели); на длинных списках шапка должна оставаться на виду; цена — вложенная вертикальная прокрутка (на телефонах высотой < 700 px её нет) | снять sticky/z-index и принять уходящую шапку |
| 51 | 24.10.2026 | Task 7, fix T7.I2: порядок «страница/дерево» в `WikiPage` определяется `ResizeObserver` на `.wiki__body` с порогом `≤ 759.98` px — тем же, что `@container page` в `wiki.css`; `matchMedia` убран | окно и контейнер страницы (рядом боковая панель) расходятся, на окне ≈ 760–1050 px CSS уже в одну колонку, а DOM ещё нет; до первого замера (и без `ResizeObserver`) — широкая раскладка; ключи панелей сохраняют состояние дерева | вернуть `matchMedia` с запасом по ширине панели | | 51 | 24.10.2026 | Task 7, fix T7.I2: порядок «страница/дерево» в `WikiPage` определяется `ResizeObserver` на `.wiki__body` с порогом `≤ 759.98` px — тем же, что `@container page` в `wiki.css`; `matchMedia` убран | окно и контейнер страницы (рядом боковая панель) расходятся, на окне ≈ 760–1050 px CSS уже в одну колонку, а DOM ещё нет; до первого замера (и без `ResizeObserver`) — широкая раскладка; ключи панелей сохраняют состояние дерева | вернуть `matchMedia` с запасом по ширине панели |
| 52 | 24.10.2026 | Task 7, fix T7.I2: `useWikiDownload` возвращает объект через `useMemo` | перерисовка `WikiPage` (замер ширины) пересоздавала `{start,status}` → набор компонентов Markdown → ссылки-вложения перемонтировались (тест M-15 обнаружил); так же ломали бы их ввод в поиск и опрос статуса | не трогать хук и убрать состояние замера | | 52 | 24.10.2026 | Task 7, fix T7.I2: `useWikiDownload` возвращает объект через `useMemo` | перерисовка `WikiPage` (замер ширины) пересоздавала `{start,status}` → набор компонентов Markdown → ссылки-вложения перемонтировались (тест M-15 обнаружил); так же ломали бы их ввод в поиск и опрос статуса | не трогать хук и убрать состояние замера |
| 53 | 24.10.2026 | Task 8: проверка «без горизонтальной прокрутки» на 1440 px — `document.documentElement.scrollWidth <= window.innerWidth`, а не `===` | при классической полосе прокрутки Chromium `scrollWidth` равен ширине без полосы (1425 против 1440), равенство невозможно; смысл проверки — отсутствие переполнения — сохранён | ложное «зелёное» только если переполнение скрыто тем же зазором; на 390 px (оверлейные полосы) проверка прежняя и строгая |
| 54 | 24.10.2026 | Task 8, дефект: у `.issue-table-wrap` добавлен `position: relative` | скрытый текст «, просрочено» (`.visually-hidden`, `position:absolute`) не имел позиционированного предка внутри обёртки и выходил за её прокрутку: на 390 px `scrollWidth` страницы 778 при окне 390 | без правки на узком экране у «Задач» появлялась горизонтальная прокрутка всей страницы |
| 55 | 24.10.2026 | Task 8: «Последние пакеты проекта» проверяются пакетом «Обновить вики» (пакет проекта), а не «Обновить из Jira»; для событий пакета Jira в e2e — хелпер `jiraEvent` (строка модуля в панели / полоса на узком экране) | пакет «Обновить из Jira» не привязан к проекту (охват «все проекты») и в список проекта не попадает; на странице несколько `role=status`, прежний `getByRole("status")` неоднозначен | только тесты; список пакетов проекта и так фильтруется хабом по `project` |

View File

@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { CW } from "../src/modules/jira/board/constants.ts"; import { CW } from "../src/modules/jira/board/constants.ts";
import { agentServeRefresh, login } from "./hub.ts"; import { agentServeRefresh, jiraEvent, login } from "./hub.ts";
test.beforeEach(async ({ page }) => { await login(page); }); test.beforeEach(async ({ page }) => { await login(page); });
@ -42,20 +42,21 @@ test("«по датам» — колонки дат, подписи и стре
}); });
test("«Обновить из Jira» с учебным агентом доходит до конца", async ({ page }) => { test("«Обновить из Jira» с учебным агентом доходит до конца", async ({ page }) => {
const btn = page.getByRole("button", { name: "Обновить из Jira" }); // на странице две кнопки: значок в панели («— все проекты») и кнопка в шапке доски
const btn = page.getByTestId("page-board").getByRole("button", { name: "Обновить из Jira", exact: true });
await btn.click(); await btn.click();
await expect(btn).toBeDisabled(); await expect(btn).toBeDisabled();
await expect(page.getByRole("status")).toContainText(/пакет #\d+/); await expect(jiraEvent(page)).toContainText(/пакет #\d+/);
await agentServeRefresh(); await agentServeRefresh();
await expect(page.getByRole("status")).toContainText(/пакет #\d+ выполнен/, { timeout: 15_000 }); await expect(jiraEvent(page)).toContainText(/пакет #\d+ выполнен/, { timeout: 15_000 });
await expect(btn).toBeEnabled(); await expect(btn).toBeEnabled();
await expect(page.getByText(/^данные на /)).toBeVisible(); await expect(page.getByText(/^данные на .* · все проекты$/)).toBeVisible();
await expect(page.locator(".board .card[data-key]")).not.toHaveCount(0); await expect(page.locator(".board .card[data-key]")).not.toHaveCount(0);
}); });
test("ⓘ открывает карточку окном поверх доски", async ({ page }) => { test("ⓘ открывает карточку окном поверх доски", async ({ page }) => {
await page.locator('.board .card[data-key="AURORA-9001"]').getByRole("link", { name: "Открыть карточку AURORA-9001" }).click(); await page.locator('.board .card[data-key="AURORA-9001"]').getByRole("link", { name: "Открыть карточку AURORA-9001" }).click();
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/); await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001$/);
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible(); await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
await expect(page.getByTestId("page-board")).toBeAttached(); await expect(page.getByTestId("page-board")).toBeAttached();
await expect(page.getByRole("link", { name: "Доска связей", exact: true })).toHaveAttribute("aria-current", "page"); await expect(page.getByRole("link", { name: "Доска связей", exact: true })).toHaveAttribute("aria-current", "page");
@ -79,12 +80,12 @@ test("M-3: прокрутка и данные доски после окна к
await page.locator(`.board .card[data-key="${key}"] a.open-card`).click(); await page.locator(`.board .card[data-key="${key}"] a.open-card`).click();
await expect(page.getByRole("dialog")).toBeVisible(); await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
expect(boardLoads).toBe(0); // доска не перезагружалась expect(boardLoads).toBe(0); // доска не перезагружалась
// боковая панель закреплена (#69): ссылки разделов видны и после прокрутки, щелчок не двигает доску // боковая панель закреплена (#69): ссылки разделов видны и после прокрутки, щелчок не двигает доску
await page.getByRole("link", { name: "Задачи", exact: true }).click(); await page.getByRole("link", { name: "Задачи", exact: true }).click();
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await expect(page).toHaveURL(/\/app\/jira\/issues\?project=AWPCA$/);
await page.getByRole("link", { name: "Доска связей", exact: true }).click(); await page.getByRole("link", { name: "Доска связей", exact: true }).click();
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
}); });

View File

@ -18,7 +18,7 @@ async function seedHistory() {
test("лента: событие за сегодня, карточка окном поверх ленты, история в карточке", async ({ page }) => { test("лента: событие за сегодня, карточка окном поверх ленты, история в карточке", async ({ page }) => {
await seedHistory(); await seedHistory();
await login(page, "/app/feed?project=AWPCA"); await login(page, "/app/jira/feed?project=AWPCA");
const group = page.getByTestId("feed-group").filter({ has: page.getByRole("link", { name: "AURORA-9002", exact: true }) }); const group = page.getByTestId("feed-group").filter({ has: page.getByRole("link", { name: "AURORA-9002", exact: true }) });
await expect(group.getByText("статус «В работе» → «Готово»").first()).toBeVisible(); await expect(group.getByText("статус «В работе» → «Готово»").first()).toBeVisible();
const other = page.getByRole("checkbox", { name: "Прочие поля" }); const other = page.getByRole("checkbox", { name: "Прочие поля" });
@ -39,7 +39,7 @@ test("лента: событие за сегодня, карточка окно
test("лента на 390 px: нет горизонтальной прокрутки при длинных значениях", async ({ page }) => { test("лента на 390 px: нет горизонтальной прокрутки при длинных значениях", async ({ page }) => {
await seedHistory(); await seedHistory();
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await login(page, "/app/feed?project=AWPCA&kinds=status,other"); await login(page, "/app/jira/feed?project=AWPCA&kinds=status,other");
await expect(page.getByTestId("feed-group").first()).toBeVisible(); await expect(page.getByTestId("feed-group").first()).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(0); expect(overflow).toBeLessThanOrEqual(0);

View File

@ -5,12 +5,12 @@ import { HUB_HOME, HUB_URL } from "../playwright.config.ts";
export const tokens = () => JSON.parse(readFileSync(`${HUB_HOME}/tokens.json`, "utf8")) as { client: string; agents: Record<string, string> }; export const tokens = () => JSON.parse(readFileSync(`${HUB_HOME}/tokens.json`, "utf8")) as { client: string; agents: Record<string, string> };
const BASE = HUB_URL; const BASE = HUB_URL;
export async function login(page: Page, path = "/app/board") { export async function login(page: Page, path = "/app/jira/board") {
await page.goto(path); await page.goto(path);
await page.getByLabel("Клиентский токен хаба").fill(tokens().client); await page.getByLabel("Клиентский токен хаба").fill(tokens().client);
await page.getByRole("button", { name: "Войти" }).click(); await page.getByRole("button", { name: "Войти" }).click();
// оболочка: на широком экране — ссылки разделов в боковой панели, на узком — кнопка «Меню» (#69) // оболочка: на широком экране — ссылки разделов в боковой панели, на узком — кнопка «Меню» (#69)
await expect(page.getByRole("link", { name: "Доска связей" }).or(page.getByRole("button", { name: "Меню" }))).toBeVisible(); await expect(page.getByRole("complementary", { name: "Панель приложения" }).getByRole("link", { name: "Доска связей" }).or(page.getByRole("button", { name: "Меню" }))).toBeVisible();
} }
async function call(path: string, body: unknown) { async function call(path: string, body: unknown) {
@ -27,3 +27,8 @@ export async function agentServeRefresh() {
await call(`/agent/commands/${command.id}/result`, { status: "done", result: { issues: 0 }, jira_calls: 1, rate_limited: 0 }); await call(`/agent/commands/${command.id}/result`, { status: "done", result: { issues: 0 }, jira_calls: 1, rate_limited: 0 });
return command.id as number; return command.id as number;
} }
// событие пакета Jira: на широком экране — строка модуля в боковой панели, на узком — полоса под верхней строкой
export const jiraEvent = (page: Page) =>
page.getByRole("complementary", { name: "Панель приложения" }).locator('[data-module="jira"]').getByRole("status")
.or(page.getByTestId("topbar-events"));

View File

@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
import { login } from "./hub.ts"; import { login } from "./hub.ts";
test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => { test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => {
await login(page, "/app/issues"); await login(page, "/app/jira/issues");
await page.getByRole("button", { name: /Фильтры/ }).click(); await page.getByRole("button", { name: /Фильтры/ }).click();
await page.getByRole("combobox", { name: "Исполнитель" }).selectOption("Петров Пётр Петрович"); await page.getByRole("combobox", { name: "Исполнитель" }).selectOption("Петров Пётр Петрович");
await expect(page).toHaveURL(/assignee=/); await expect(page).toHaveURL(/assignee=/);
@ -18,12 +18,12 @@ test("фильтр → карточка окном → заметка сохра
await expect(page.getByRole("dialog").getByText("e2e: дописать тесты")).toBeVisible(); await expect(page.getByRole("dialog").getByText("e2e: дописать тесты")).toBeVisible();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA&assignee=/); await expect(page).toHaveURL(/\/app\/jira\/issues\?project=AWPCA&assignee=/);
await expect(page.getByRole("combobox", { name: "Исполнитель" })).toHaveValue("Петров Пётр Петрович"); // панель осталась раскрытой (sessionStorage) await expect(page.getByRole("combobox", { name: "Исполнитель" })).toHaveValue("Петров Пётр Петрович"); // панель осталась раскрытой (sessionStorage)
}); });
test("фильтр в адресе в новой вкладке: панель свёрнута, плашка и счётчик видны, плашка снимает фильтр", async ({ page }) => { test("фильтр в адресе в новой вкладке: панель свёрнута, плашка и счётчик видны, плашка снимает фильтр", async ({ page }) => {
await login(page, "/app/issues?project=AWPCA&assignee=" + encodeURIComponent("Петров Пётр Петрович")); await login(page, "/app/jira/issues?project=AWPCA&assignee=" + encodeURIComponent("Петров Пётр Петрович"));
await page.evaluate(() => sessionStorage.clear()); await page.evaluate(() => sessionStorage.clear());
await page.reload(); await page.reload();
const btn = page.getByRole("button", { name: /Фильтры/ }); const btn = page.getByRole("button", { name: /Фильтры/ });
@ -37,15 +37,15 @@ test("фильтр в адресе в новой вкладке: панель с
}); });
test("щелчок по ячейке строки открывает карточку, по выделенному тексту — нет", async ({ page }) => { test("щелчок по ячейке строки открывает карточку, по выделенному тексту — нет", async ({ page }) => {
await login(page, "/app/issues"); await login(page, "/app/jira/issues");
await page.getByRole("cell", { name: "Схема свода" }).click(); await page.getByRole("cell", { name: "Схема свода" }).click();
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001/); await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/);
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible(); await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
}); });
test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => { test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 320 }); await page.setViewportSize({ width: 1280, height: 320 });
await login(page, "/app/issues"); await login(page, "/app/jira/issues");
await expect(page.locator("table.issue-table tbody tr")).toHaveCount(9); // AURORA-9006 «Готово» скрыта await expect(page.locator("table.issue-table tbody tr")).toHaveCount(9); // AURORA-9006 «Готово» скрыта
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; }); const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
expect(y).toBeGreaterThan(50); expect(y).toBeGreaterThan(50);
@ -63,37 +63,37 @@ test("переход по связи и «назад»; ✕ и щелчок по
await page.getByRole("link", { name: "AURORA-9001", exact: true }).click(); await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
const d = page.getByRole("dialog"); const d = page.getByRole("dialog");
await d.locator(".links-sec").getByRole("link", { name: "AURORA-9002", exact: true }).click(); await d.locator(".links-sec").getByRole("link", { name: "AURORA-9002", exact: true }).click();
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9002$/); await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9002$/);
await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible(); await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
await page.goBack(); await page.goBack();
await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
await d.locator(".links-sec").getByRole("link", { name: "AURORA-9002", exact: true }).click(); await d.locator(".links-sec").getByRole("link", { name: "AURORA-9002", exact: true }).click();
await d.getByRole("button", { name: "Закрыть" }).click(); await d.getByRole("button", { name: "Закрыть" }).click();
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await expect(page).toHaveURL(/\/app\/jira\/issues\?project=AWPCA$/);
await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByRole("dialog")).toHaveCount(0);
await page.getByRole("link", { name: "AURORA-9003", exact: true }).click(); await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible(); await expect(page.getByRole("dialog")).toBeVisible();
await page.mouse.click(5, 300); // фон слева от окна await page.mouse.click(5, 300); // фон слева от окна
await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByRole("dialog")).toHaveCount(0);
await page.goBack(); await page.goBack();
await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByRole("dialog")).toHaveCount(0);
}); });
test("прямой адрес и перезагрузка: окно поверх списка, ✕ ведёт в список", async ({ page }) => { test("прямой адрес и перезагрузка: окно поверх списка, ✕ ведёт в список", async ({ page }) => {
await login(page, "/app/issues/AURORA-9001"); await login(page, "/app/jira/issues/AURORA-9001");
const d = page.getByRole("dialog"); const d = page.getByRole("dialog");
await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
await expect(page.getByTestId("page-issues")).toBeAttached(); await expect(page.getByTestId("page-issues")).toBeAttached();
await page.reload(); await page.reload();
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible(); await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
await page.getByRole("dialog").getByRole("button", { name: "Закрыть" }).click(); await page.getByRole("dialog").getByRole("button", { name: "Закрыть" }).click();
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await expect(page).toHaveURL(/\/app\/jira\/issues\?project=AWPCA$/);
await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByRole("dialog")).toHaveCount(0);
}); });
test("карточка: описание свёрнуто, комментарии новые сверху и скрываются с запоминанием, команды свёрнуты", async ({ page }) => { test("карточка: описание свёрнуто, комментарии новые сверху и скрываются с запоминанием, команды свёрнуты", async ({ page }) => {
await login(page, "/app/issues/AURORA-9001"); await login(page, "/app/jira/issues/AURORA-9001");
const d = page.getByRole("dialog"); const d = page.getByRole("dialog");
await expect(d.getByRole("heading", { name: "Комментарии (3)" })).toBeVisible(); await expect(d.getByRole("heading", { name: "Комментарии (3)" })).toBeVisible();
await expect(d.getByTestId("comments").getByRole("listitem").first()).toContainText("[мост] служебный комментарий"); await expect(d.getByTestId("comments").getByRole("listitem").first()).toContainText("[мост] служебный комментарий");
@ -106,7 +106,7 @@ test("карточка: описание свёрнуто, комментари
expect((await d.locator("div.description").boundingBox())!.height).toBeGreaterThan(hClamped); expect((await d.locator("div.description").boundingBox())!.height).toBeGreaterThan(hClamped);
await expect(d.getByText("Конец описания.")).toBeVisible(); await expect(d.getByText("Конец описания.")).toBeVisible();
await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeHidden(); await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeHidden();
await d.getByText("Команды моста (2)").click(); await d.getByText("Команды хаба (2)").click();
await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeVisible(); await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeVisible();
await d.getByRole("button", { name: "Скрыть" }).click(); await d.getByRole("button", { name: "Скрыть" }).click();
await expect(d.getByText("Первый комментарий e2e")).toHaveCount(0); await expect(d.getByText("Первый комментарий e2e")).toHaveCount(0);
@ -116,7 +116,7 @@ test("карточка: описание свёрнуто, комментари
}); });
test("разметка Jira: описание и комментарии отрендерены, «исходник» у каждого блока, ключ задачи — ссылка", async ({ page }) => { test("разметка Jira: описание и комментарии отрендерены, «исходник» у каждого блока, ключ задачи — ссылка", async ({ page }) => {
await login(page, "/app/issues/AURORA-9001"); await login(page, "/app/jira/issues/AURORA-9001");
const d = page.getByRole("dialog"); const d = page.getByRole("dialog");
const desc = d.locator("section", { has: page.getByRole("heading", { name: "Описание" }) }); const desc = d.locator("section", { has: page.getByRole("heading", { name: "Описание" }) });
await expect(desc.getByRole("heading", { level: 3, name: "Контекст" })).toBeVisible(); await expect(desc.getByRole("heading", { level: 3, name: "Контекст" })).toBeVisible();
@ -144,7 +144,7 @@ test("разметка Jira: описание и комментарии отре
}); });
test("C-1: повторное открытие из кэша — длинное описание свёрнуто с кнопкой «развернуть»", async ({ page }) => { test("C-1: повторное открытие из кэша — длинное описание свёрнуто с кнопкой «развернуть»", async ({ page }) => {
await login(page, "/app/issues"); await login(page, "/app/jira/issues");
await page.getByRole("link", { name: "AURORA-9001", exact: true }).click(); await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
const d = page.getByRole("dialog"); const d = page.getByRole("dialog");
await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible(); await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible();
@ -156,7 +156,7 @@ test("C-1: повторное открытие из кэша — длинное
test("I-1: телефон 375 px — таблица описания прокручивается внутри, заголовки таблицы без чужого стиля", async ({ page }) => { test("I-1: телефон 375 px — таблица описания прокручивается внутри, заголовки таблицы без чужого стиля", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 }); await page.setViewportSize({ width: 375, height: 800 });
await login(page, "/app/issues/AURORA-9001"); await login(page, "/app/jira/issues/AURORA-9001");
const d = page.getByRole("dialog"); const d = page.getByRole("dialog");
await d.getByRole("button", { name: "развернуть" }).click(); await d.getByRole("button", { name: "развернуть" }).click();
const th = d.getByRole("columnheader", { name: "Поле", exact: true }); const th = d.getByRole("columnheader", { name: "Поле", exact: true });
@ -167,7 +167,7 @@ test("I-1: телефон 375 px — таблица описания прокр
}); });
test("несохранённая заметка: Esc спрашивает, «Остаться» оставляет окно, «Уйти» закрывает", async ({ page }) => { test("несохранённая заметка: Esc спрашивает, «Остаться» оставляет окно, «Уйти» закрывает", async ({ page }) => {
await login(page, "/app/issues"); await login(page, "/app/jira/issues");
await page.getByRole("link", { name: "AURORA-9003", exact: true }).click(); await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
const d = page.getByRole("dialog"); const d = page.getByRole("dialog");
await d.getByRole("button", { name: "Править" }).click(); await d.getByRole("button", { name: "Править" }).click();
@ -179,12 +179,12 @@ test("несохранённая заметка: Esc спрашивает, «О
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await d.getByRole("button", { name: "Уйти" }).click(); await d.getByRole("button", { name: "Уйти" }).click();
await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await expect(page).toHaveURL(/\/app\/jira\/issues\?project=AWPCA$/);
}); });
test("узкий экран: окно на всю ширину, заметки выше связей, без горизонтальной прокрутки", async ({ page }) => { test("узкий экран: окно на всю ширину, заметки выше связей, без горизонтальной прокрутки", async ({ page }) => {
await page.setViewportSize({ width: 600, height: 800 }); await page.setViewportSize({ width: 600, height: 800 });
await login(page, "/app/issues/AURORA-9001"); await login(page, "/app/jira/issues/AURORA-9001");
const d = page.getByRole("dialog"); const d = page.getByRole("dialog");
await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
expect((await d.boundingBox())!.width).toBeGreaterThan(590); expect((await d.boundingBox())!.width).toBeGreaterThan(590);
@ -198,7 +198,7 @@ test("узкий экран: окно на всю ширину, заметки
}); });
test("закрытые скрыты по умолчанию, «Все» показывает их", async ({ page }) => { test("закрытые скрыты по умолчанию, «Все» показывает их", async ({ page }) => {
await login(page, "/app/issues"); await login(page, "/app/jira/issues");
await expect(page.locator("table.issue-table tbody tr")).toHaveCount(9); await expect(page.locator("table.issue-table tbody tr")).toHaveCount(9);
await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toHaveCount(0); await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toHaveCount(0);
await page.getByRole("radio", { name: "Все" }).click(); await page.getByRole("radio", { name: "Все" }).click();

View File

@ -24,7 +24,7 @@ test("свёрнутая панель переживает перезагруз
await expect(page.getByRole("button", { name: "Развернуть панель", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Развернуть панель", exact: true })).toBeVisible();
await expect.poll(async () => (await wrap.boundingBox())!.width).toBeGreaterThan(before + 150); await expect.poll(async () => (await wrap.boundingBox())!.width).toBeGreaterThan(before + 150);
await page.getByRole("link", { name: "Задачи" }).click(); await page.getByRole("link", { name: "Задачи" }).click();
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await expect(page).toHaveURL(/\/app\/jira\/issues\?project=AWPCA$/);
await page.getByRole("button", { name: "Развернуть панель", exact: true }).click(); await page.getByRole("button", { name: "Развернуть панель", exact: true }).click();
await expect(page.getByRole("button", { name: "Свернуть панель" })).toBeVisible(); await expect(page.getByRole("button", { name: "Свернуть панель" })).toBeVisible();
}); });
@ -37,7 +37,7 @@ test("390 px: «Меню» открывает панель, «Задачи» з
const side = page.getByRole("complementary", { name: "Панель приложения" }); const side = page.getByRole("complementary", { name: "Панель приложения" });
await expect(side).toBeVisible(); await expect(side).toBeVisible();
await side.getByRole("link", { name: "Задачи" }).click(); await side.getByRole("link", { name: "Задачи" }).click();
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await expect(page).toHaveURL(/\/app\/jira\/issues\?project=AWPCA$/);
await expect(side).toHaveCount(0); await expect(side).toHaveCount(0);
await expect(page.getByTestId("page-issues")).toBeVisible(); await expect(page.getByTestId("page-issues")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
@ -45,7 +45,7 @@ test("390 px: «Меню» открывает панель, «Задачи» з
test("C-1: на средних ширинах страница не шире окна — «Задачи» на 1280, доска на 1000 прокручивается сама", async ({ page }) => { test("C-1: на средних ширинах страница не шире окна — «Задачи» на 1280, доска на 1000 прокручивается сама", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 }); await page.setViewportSize({ width: 1280, height: 800 });
await login(page, "/app/issues"); await login(page, "/app/jira/issues");
await expect(page.locator("table.issue-table tbody tr").first()).toBeVisible(); await expect(page.locator("table.issue-table tbody tr").first()).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1280); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1280);
await page.setViewportSize({ width: 1000, height: 800 }); await page.setViewportSize({ width: 1000, height: 800 });
@ -58,3 +58,35 @@ test("C-1: на средних ширинах страница не шире о
const wrap = page.locator(".wrap"); const wrap = page.locator(".wrap");
expect(await wrap.evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true); // доска шире своей рамки — прокрутка внутри expect(await wrap.evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true); // доска шире своей рамки — прокрутка внутри
}); });
test("1440 px: «Задачи» не шире окна, h1 «Вики» — 20px", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await login(page, "/app/jira/issues");
await expect(page.locator("table.issue-table tbody tr").first()).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.getByRole("complementary", { name: "Панель приложения" }).getByRole("link", { name: "Вики" }).click();
await expect(page).toHaveURL(/\/app\/wiki\?project=AWPCA$/);
const h1 = page.getByRole("heading", { level: 1, name: "Вики" });
await expect(h1).toBeVisible();
expect(await h1.evaluate((el) => getComputedStyle(el).fontSize)).toBe("20px");
});
test("/app/board → /app/jira/board с сохранением search", async ({ page }) => {
await login(page);
await page.goto("/app/board?project=AWPCA");
await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
});
test("390 px: метка проекта в верхней строке, «Меню» модально", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 });
await login(page);
const bar = page.getByRole("banner", { name: "Строка приложения" });
await expect(bar.getByText("AWPCA", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Меню" }).click();
await expect(page.getByRole("complementary", { name: "Панель приложения" })).toBeVisible();
await expect(bar).toHaveAttribute("inert", "");
await expect(page.locator("main")).toHaveAttribute("inert", "");
await page.keyboard.press("Escape");
await expect(page.getByRole("complementary", { name: "Панель приложения" })).toHaveCount(0);
await expect(bar).not.toHaveAttribute("inert", "");
});

View File

@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
import { login } from "./hub.ts"; import { login } from "./hub.ts";
test("неверный токен — пояснение; верный — доска", async ({ page }) => { test("неверный токен — пояснение; верный — доска", async ({ page }) => {
await page.goto("/app/board"); await page.goto("/app/jira/board");
await page.getByLabel("Клиентский токен хаба").fill("wrong-token"); await page.getByLabel("Клиентский токен хаба").fill("wrong-token");
await page.getByRole("button", { name: "Войти" }).click(); await page.getByRole("button", { name: "Войти" }).click();
await expect(page.getByText(/Токен не подошёл/)).toBeVisible(); await expect(page.getByText(/Токен не подошёл/)).toBeVisible();
@ -10,8 +10,13 @@ test("неверный токен — пояснение; верный — до
await expect(page.locator(".card[data-key]").first()).toBeVisible(); await expect(page.locator(".card[data-key]").first()).toBeVisible();
}); });
test("/ и /app ведут на доску", async ({ page }) => { test("прежние адреса ведут на страницы модулей, /app — на «Обзор»", async ({ page }) => {
await login(page); await login(page);
await page.goto("/app"); await page.goto("/app");
await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); await expect(page).toHaveURL(/\/app\/?\?project=AWPCA$/);
await expect(page.getByTestId("page-overview")).toBeVisible();
await page.goto("/app/board?project=AWPCA");
await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
await page.goto("/app/issues/AURORA-9001?project=AWPCA");
await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001\?project=AWPCA$/);
}); });

34
web/e2e/overview.spec.ts Normal file
View File

@ -0,0 +1,34 @@
import { expect, test } from "@playwright/test";
import { agentServeRefresh, login } from "./hub.ts";
test("«Обзор»: / → /app/?project=…, две карточки модулей, список пакетов", async ({ page }) => {
await login(page, "/app/");
await expect(page).toHaveURL(/\/app\/?\?project=AWPCA$/);
await expect(page.getByTestId("page-overview")).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: "Обзор" })).toBeVisible();
const cards = page.locator(".overview-card");
await expect(cards).toHaveCount(2);
await expect(page.getByRole("region", { name: "Jira" })).toBeVisible();
await expect(page.getByRole("region", { name: "Вики" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Последние пакеты проекта" })).toBeVisible();
});
test("«Обзор»: «Обновить из Jira» ставит пакет и доводит до конца", async ({ page }) => {
await login(page, "/app/");
const card = page.getByRole("region", { name: "Jira" });
await card.getByRole("button", { name: "Обновить из Jira" }).click();
await expect(card.getByRole("status")).toContainText(/пакет #\d+/);
await agentServeRefresh();
await expect(card.getByRole("status")).toContainText(/выполнен/, { timeout: 15_000 });
});
test("«Обзор»: пакет проекта («Обновить вики») появляется в «Последних пакетах проекта»", async ({ page }) => {
await login(page, "/app/");
await expect(page.getByText("Пакетов пока не было.")).toBeVisible();
await page.getByRole("region", { name: "Вики" }).getByRole("button", { name: "Обновить вики" }).click();
// список пакетов опрашивается раз в 15 с; агента wiki в тестовом хабе нет — пакет остаётся в очереди
const row = page.locator("table.overview-table tbody tr").first();
await expect(row).toBeVisible({ timeout: 25_000 });
await expect(row).toContainText("Вики");
await expect(row).toContainText("в очереди");
});

View File

@ -5,7 +5,7 @@ import { agentServeRefresh, login } from "./hub.ts";
test.describe.serial("проекты", () => { test.describe.serial("проекты", () => {
test("первый вход: проект AWPCA в адресе, на доске задача проекта", async ({ page }) => { test("первый вход: проект AWPCA в адресе, на доске задача проекта", async ({ page }) => {
await login(page); await login(page);
await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible(); await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible();
}); });
@ -35,15 +35,15 @@ test.describe.serial("проекты", () => {
}); });
test("переключение проекта и раздела", async ({ page }) => { test("переключение проекта и раздела", async ({ page }) => {
await login(page, "/app/board?project=STOCKS_DRRK"); await login(page, "/app/jira/board?project=STOCKS_DRRK");
await page.getByLabel("Проект", { exact: true }).selectOption({ label: "AWPCA — АРМ КА" }); await page.getByLabel("Проект", { exact: true }).selectOption({ label: "AWPCA — АРМ КА" });
await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible(); await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible();
await page.getByRole("link", { name: "Задачи", exact: true }).click(); await page.getByRole("link", { name: "Задачи", exact: true }).click();
await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await expect(page).toHaveURL(/\/app\/jira\/issues\?project=AWPCA$/);
}); });
test("неизвестный проект в адресе", async ({ page }) => { test("неизвестный проект в адресе", async ({ page }) => {
await login(page, "/app/issues?project=NOPE"); await login(page, "/app/jira/issues?project=NOPE");
await expect(page.getByText("проект NOPE не найден")).toBeVisible(); await expect(page.getByText("проект NOPE не найден")).toBeVisible();
}); });
}); });

View File

@ -1,7 +1,7 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { agentServeRefresh, login } from "./hub.ts"; import { agentServeRefresh, jiraEvent, login } from "./hub.ts";
// Снимки экранов для проверки вида (запуск: SCREENS=1 npx playwright test e2e/screens.spec.ts). // Снимки экранов для проверки вида (запуск: SCREENS=1 npx playwright test e2e/screens.spec.ts).
// Результат — web/e2e-screens/<тема>-<ширина>-<экран>.png, в git не попадает. // Результат — web/e2e-screens/<тема>-<ширина>-<экран>.png, в git не попадает.
@ -26,12 +26,20 @@ for (const theme of ["light", "dark"] as const) {
}; };
test("login", async ({ page }) => { test("login", async ({ page }) => {
await page.goto("/app/board"); await page.goto("/app/jira/board");
await expect(page.getByLabel("Клиентский токен хаба")).toBeVisible(); await expect(page.getByLabel("Клиентский токен хаба")).toBeVisible();
await settle(page); await settle(page);
await shot(page, "login"); await shot(page, "login");
}); });
test("overview", async ({ page }) => {
await login(page, "/app/");
await expect(page.getByTestId("page-overview")).toBeVisible();
await expect(page.locator(".overview-card")).toHaveCount(2);
await settle(page);
await shot(page, "overview");
});
test("board", async ({ page }) => { test("board", async ({ page }) => {
await login(page); await login(page);
await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
@ -56,14 +64,14 @@ for (const theme of ["light", "dark"] as const) {
}); });
test("issues", async ({ page }) => { test("issues", async ({ page }) => {
await login(page, "/app/issues"); await login(page, "/app/jira/issues");
await expect(page.locator("table.issue-table tbody tr").first()).toBeVisible(); await expect(page.locator("table.issue-table tbody tr").first()).toBeVisible();
await settle(page); await settle(page);
await shot(page, "issues"); await shot(page, "issues");
}); });
test("issues-filters", async ({ page }) => { test("issues-filters", async ({ page }) => {
await login(page, "/app/issues"); await login(page, "/app/jira/issues");
await page.getByRole("button", { name: /Фильтры/ }).click(); await page.getByRole("button", { name: /Фильтры/ }).click();
await page.getByRole("combobox", { name: "Исполнитель" }).selectOption("Петров Пётр Петрович"); await page.getByRole("combobox", { name: "Исполнитель" }).selectOption("Петров Пётр Петрович");
await expect(page).toHaveURL(/assignee=/); await expect(page).toHaveURL(/assignee=/);
@ -72,7 +80,7 @@ for (const theme of ["light", "dark"] as const) {
}); });
test("card", async ({ page }) => { test("card", async ({ page }) => {
await login(page, "/app/issues/AURORA-9001"); await login(page, "/app/jira/issues/AURORA-9001");
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible(); await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
await settle(page); await settle(page);
await shot(page, "card"); await shot(page, "card");
@ -105,14 +113,23 @@ for (const theme of ["light", "dark"] as const) {
await page.screenshot({ path: `${OUT}/${theme}-${w}-menu.png` }); await page.screenshot({ path: `${OUT}/${theme}-${w}-menu.png` });
}); });
test("sidebar", async ({ page }) => {
await login(page, "/app/");
if (w < 640) await page.getByRole("button", { name: "Меню" }).click();
const side = page.getByRole("complementary", { name: "Панель приложения" });
await expect(side).toBeVisible();
await settle(page);
await side.screenshot({ path: `${OUT}/${theme}-${w}-sidebar.png` });
});
test("refresh", async ({ page }) => { test("refresh", async ({ page }) => {
await login(page); await login(page);
await page.getByRole("button", { name: "Обновить из Jira" }).click(); await page.getByTestId("page-board").getByRole("button", { name: "Обновить из Jira", exact: true }).click();
await expect(page.getByRole("status")).toContainText(/пакет #\d+/); await expect(jiraEvent(page)).toContainText(/пакет #\d+/);
await settle(page); await settle(page);
await shot(page, "refresh"); await shot(page, "refresh");
await agentServeRefresh(); // не оставляем пакет в очереди тестового хаба await agentServeRefresh(); // не оставляем пакет в очереди тестового хаба
await expect(page.getByRole("status")).toContainText(/выполнен/, { timeout: 15_000 }); await expect(jiraEvent(page)).toContainText(/выполнен/, { timeout: 15_000 });
}); });
}); });
} }

View File

@ -12,7 +12,7 @@
/* таблица прокручивается внутри своей панели на любой ширине (девять столбцов шире контейнера и на 1440 px); обёртка не шире контейнера страницы. /* таблица прокручивается внутри своей панели на любой ширине (девять столбцов шире контейнера и на 1440 px); обёртка не шире контейнера страницы.
Обёртка — скроллер, поэтому липкая шапка липнет к ней, а не к окну: чтобы шапка оставалась на виду, высота обёртки ограничена высотой окна Обёртка — скроллер, поэтому липкая шапка липнет к ней, а не к окну: чтобы шапка оставалась на виду, высота обёртки ограничена высотой окна
и длинный список прокручивается внутри панели (на низких окнах — меньше 700 px — ограничения нет, шапка уходит вместе со страницей) */ и длинный список прокручивается внутри панели (на низких окнах — меньше 700 px — ограничения нет, шапка уходит вместе со страницей) */
.issue-table-wrap{overflow-x:auto;max-width:100%;min-width:0} .issue-table-wrap{position:relative;overflow-x:auto;max-width:100%;min-width:0}
@media (min-height:700px){.issue-table-wrap{max-height:calc(100vh - 220px);overflow-y:auto}} @media (min-height:700px){.issue-table-wrap{max-height:calc(100vh - 220px);overflow-y:auto}}
.issue-table th:first-child{border-top-left-radius:calc(var(--radius-xl) - 1px)} .issue-table th:first-child{border-top-left-radius:calc(var(--radius-xl) - 1px)}
.issue-table th:last-child{border-top-right-radius:calc(var(--radius-xl) - 1px)} .issue-table th:last-child{border-top-right-radius:calc(var(--radius-xl) - 1px)}