diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index be276e6..4530ed8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -89,25 +89,32 @@ общие части (`web/src/ui/` — компоненты дизайн-системы AURA, `web/src/styles/tokens.css` — токены и единственное место с литералами цвета, `web/src/api/` — клиент HTTP, `web/src/auth/`, `web/src/projects/` — проект в адресе `?project=<метка>`, выпадающий список, окно «Новый проект») и модули `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` (хуки вызываются в постоянном порядке). +Состояние модуля — данные, а не компонент: `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`), страница (`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:` скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`), -плашка синхронизации в боковой панели (`SyncStatus`); поле «Корень вики» в окне «Новый проект» — через `projectFields`. +сводка для «Обзора» (`Summary`) и `status` для ядра; поле «Корень вики» в окне «Новый проект» — через `projectFields`. -Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Доска связей» (`/app/board`), «Задачи» (`/app/issues`), «Лента» -(`/app/feed`), карточка `/app/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует +Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Доска связей» (`/app/jira/board`), «Задачи» (`/app/jira/issues`), «Лента» +(`/app/jira/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует страницу по `location.state.background`). Внутри: `board/` (чистые `model`, `analysis`, `layout`, `storage.ts` — ключи `localStorage` прежней страницы, у проекта AWPCA прежние, у остальных с суффиксом `@<метка>`; отрисовка `components/`; golden-эталоны `__fixtures__/`), `issues/` (список, карточка, `wiki/` — разбор вики-разметки, `attachments/` — список, плашки и скачивание: `POST /attachments/{id}/fetch`, опрос `GET /attachments/{id}`, затем `api.blob` на `/content`), -`feed/` (период, виды, группировка событий, `EventList`, общий с блоком «История» карточки), `refresh/` (кнопка и плашка -обновления данных из Jira в боковой панели, отслеживание пакета: `RefreshProvider`, `useJiraRefresh`), `api/queries.ts`. +`feed/` (период, виды, группировка событий, `EventList`, общий с блоком «История» карточки), `RefreshButton.tsx` (кнопка «Обновить из Jira» в шапках страниц), `Summary`/`status` для ядра, `api/queries.ts`. Собирается `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`. Цепочки зависимостей (Python): diff --git a/CLAUDE.md b/CLAUDE.md index 06c66f9..8c66522 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. - Граница: ядро не импортирует модули (кроме ленивого импорта в `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`. -- Веб: оболочка и общее — `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`. ## Команды @@ -26,7 +26,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА: - `uv sync` — установка окружения. - `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. - `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` (только при смене фикстур). - `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/<модуль>/`). diff --git a/README.md b/README.md index 52a817a..52b9e04 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py | `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `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 /`, `GET /links` | — | перенаправление на `/app/board` | +| `GET /`, `GET /links` | — | перенаправление на `/app/` («Обзор») | | `PUT /issues/{key}/note` | клиент | заметки (частично) | | `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта | | `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, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и -`/links` перенаправляют на доску. Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере; +Адреса: `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` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/issues`, `/app/feed` ведут на страницы модуля Jira. Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере; `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini: ``` diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md index 99c3fd6..eb406bb 100644 --- a/openspec/changes/web-modules/design.md +++ b/openspec/changes/web-modules/design.md @@ -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 и принять уходящую шапку | | 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 обнаружил); так же ломали бы их ввод в поиск и опрос статуса | не трогать хук и убрать состояние замера | +| 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` | diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index c35a536..6ccfce5 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; 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); }); @@ -42,20 +42,21 @@ test("«по датам» — колонки дат, подписи и стре }); 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 expect(btn).toBeDisabled(); - await expect(page.getByRole("status")).toContainText(/пакет #\d+/); + await expect(jiraEvent(page)).toContainText(/пакет #\d+/); 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(page.getByText(/^данные на /)).toBeVisible(); + await expect(page.getByText(/^данные на .* · все проекты$/)).toBeVisible(); await expect(page.locator(".board .card[data-key]")).not.toHaveCount(0); }); test("ⓘ открывает карточку окном поверх доски", async ({ page }) => { 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.getByTestId("page-board")).toBeAttached(); 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 expect(page.getByRole("dialog")).toBeVisible(); 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(boardLoads).toBe(0); // доска не перезагружалась // боковая панель закреплена (#69): ссылки разделов видны и после прокрутки, щелчок не двигает доску 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 expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); }); diff --git a/web/e2e/feed.spec.ts b/web/e2e/feed.spec.ts index ab57064..c57eec4 100644 --- a/web/e2e/feed.spec.ts +++ b/web/e2e/feed.spec.ts @@ -18,7 +18,7 @@ async function seedHistory() { test("лента: событие за сегодня, карточка окном поверх ленты, история в карточке", async ({ page }) => { 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 }) }); await expect(group.getByText("статус «В работе» → «Готово»").first()).toBeVisible(); const other = page.getByRole("checkbox", { name: "Прочие поля" }); @@ -39,7 +39,7 @@ test("лента: событие за сегодня, карточка окно test("лента на 390 px: нет горизонтальной прокрутки при длинных значениях", async ({ page }) => { await seedHistory(); 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(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); expect(overflow).toBeLessThanOrEqual(0); diff --git a/web/e2e/hub.ts b/web/e2e/hub.ts index 5f3eabd..9641d4e 100644 --- a/web/e2e/hub.ts +++ b/web/e2e/hub.ts @@ -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 }; 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.getByLabel("Клиентский токен хаба").fill(tokens().client); await page.getByRole("button", { name: "Войти" }).click(); // оболочка: на широком экране — ссылки разделов в боковой панели, на узком — кнопка «Меню» (#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) { @@ -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 }); 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")); diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts index 60bd895..a369251 100644 --- a/web/e2e/issues.spec.ts +++ b/web/e2e/issues.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import { login } from "./hub.ts"; test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => { - await login(page, "/app/issues"); + await login(page, "/app/jira/issues"); await page.getByRole("button", { name: /Фильтры/ }).click(); await page.getByRole("combobox", { name: "Исполнитель" }).selectOption("Петров Пётр Петрович"); await expect(page).toHaveURL(/assignee=/); @@ -18,12 +18,12 @@ test("фильтр → карточка окном → заметка сохра await expect(page.getByRole("dialog").getByText("e2e: дописать тесты")).toBeVisible(); await page.keyboard.press("Escape"); 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) }); 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.reload(); const btn = page.getByRole("button", { name: /Фильтры/ }); @@ -37,15 +37,15 @@ test("фильтр в адресе в новой вкладке: панель с }); test("щелчок по ячейке строки открывает карточку, по выделенному тексту — нет", async ({ page }) => { - await login(page, "/app/issues"); + await login(page, "/app/jira/issues"); 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(); }); test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => { 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 «Готово» скрыта const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; }); expect(y).toBeGreaterThan(50); @@ -63,37 +63,37 @@ test("переход по связи и «назад»; ✕ и щелчок по await page.getByRole("link", { name: "AURORA-9001", exact: true }).click(); const d = page.getByRole("dialog"); 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 page.goBack(); await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); await d.locator(".links-sec").getByRole("link", { name: "AURORA-9002", exact: true }).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 page.getByRole("link", { name: "AURORA-9003", exact: true }).click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.mouse.click(5, 300); // фон слева от окна await expect(page.getByRole("dialog")).toHaveCount(0); 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); }); test("прямой адрес и перезагрузка: окно поверх списка, ✕ ведёт в список", async ({ page }) => { - await login(page, "/app/issues/AURORA-9001"); + await login(page, "/app/jira/issues/AURORA-9001"); const d = page.getByRole("dialog"); await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); await expect(page.getByTestId("page-issues")).toBeAttached(); await page.reload(); await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible(); 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); }); test("карточка: описание свёрнуто, комментарии новые сверху и скрываются с запоминанием, команды свёрнуты", async ({ page }) => { - await login(page, "/app/issues/AURORA-9001"); + await login(page, "/app/jira/issues/AURORA-9001"); const d = page.getByRole("dialog"); await expect(d.getByRole("heading", { name: "Комментарии (3)" })).toBeVisible(); await expect(d.getByTestId("comments").getByRole("listitem").first()).toContainText("[мост] служебный комментарий"); @@ -106,7 +106,7 @@ test("карточка: описание свёрнуто, комментари expect((await d.locator("div.description").boundingBox())!.height).toBeGreaterThan(hClamped); await expect(d.getByText("Конец описания.")).toBeVisible(); 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 d.getByRole("button", { name: "Скрыть" }).click(); await expect(d.getByText("Первый комментарий e2e")).toHaveCount(0); @@ -116,7 +116,7 @@ test("карточка: описание свёрнуто, комментари }); 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 desc = d.locator("section", { has: page.getByRole("heading", { name: "Описание" }) }); await expect(desc.getByRole("heading", { level: 3, name: "Контекст" })).toBeVisible(); @@ -144,7 +144,7 @@ test("разметка Jira: описание и комментарии отре }); 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(); const d = page.getByRole("dialog"); await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible(); @@ -156,7 +156,7 @@ test("C-1: повторное открытие из кэша — длинное test("I-1: телефон 375 px — таблица описания прокручивается внутри, заголовки таблицы без чужого стиля", async ({ page }) => { 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"); await d.getByRole("button", { name: "развернуть" }).click(); const th = d.getByRole("columnheader", { name: "Поле", exact: true }); @@ -167,7 +167,7 @@ test("I-1: телефон 375 px — таблица описания прокр }); 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(); const d = page.getByRole("dialog"); await d.getByRole("button", { name: "Править" }).click(); @@ -179,12 +179,12 @@ test("несохранённая заметка: Esc спрашивает, «О await page.keyboard.press("Escape"); await d.getByRole("button", { name: "Уйти" }).click(); 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 }) => { 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"); await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); expect((await d.boundingBox())!.width).toBeGreaterThan(590); @@ -198,7 +198,7 @@ test("узкий экран: окно на всю ширину, заметки }); 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.getByRole("link", { name: "AURORA-9006", exact: true })).toHaveCount(0); await page.getByRole("radio", { name: "Все" }).click(); diff --git a/web/e2e/layout.spec.ts b/web/e2e/layout.spec.ts index 27ef928..2b9623a 100644 --- a/web/e2e/layout.spec.ts +++ b/web/e2e/layout.spec.ts @@ -24,7 +24,7 @@ test("свёрнутая панель переживает перезагруз await expect(page.getByRole("button", { name: "Развернуть панель", exact: true })).toBeVisible(); await expect.poll(async () => (await wrap.boundingBox())!.width).toBeGreaterThan(before + 150); 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 expect(page.getByRole("button", { name: "Свернуть панель" })).toBeVisible(); }); @@ -37,7 +37,7 @@ test("390 px: «Меню» открывает панель, «Задачи» з const side = page.getByRole("complementary", { name: "Панель приложения" }); await expect(side).toBeVisible(); 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(page.getByTestId("page-issues")).toBeVisible(); 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 }) => { 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(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1280); await page.setViewportSize({ width: 1000, height: 800 }); @@ -58,3 +58,35 @@ test("C-1: на средних ширинах страница не шире о const wrap = page.locator(".wrap"); 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", ""); +}); diff --git a/web/e2e/login.spec.ts b/web/e2e/login.spec.ts index d124265..9e6399e 100644 --- a/web/e2e/login.spec.ts +++ b/web/e2e/login.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import { login } from "./hub.ts"; test("неверный токен — пояснение; верный — доска", async ({ page }) => { - await page.goto("/app/board"); + await page.goto("/app/jira/board"); await page.getByLabel("Клиентский токен хаба").fill("wrong-token"); await page.getByRole("button", { name: "Войти" }).click(); await expect(page.getByText(/Токен не подошёл/)).toBeVisible(); @@ -10,8 +10,13 @@ test("неверный токен — пояснение; верный — до await expect(page.locator(".card[data-key]").first()).toBeVisible(); }); -test("/ и /app ведут на доску", async ({ page }) => { +test("прежние адреса ведут на страницы модулей, /app — на «Обзор»", async ({ page }) => { await login(page); 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$/); }); diff --git a/web/e2e/overview.spec.ts b/web/e2e/overview.spec.ts new file mode 100644 index 0000000..9cc5f9e --- /dev/null +++ b/web/e2e/overview.spec.ts @@ -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("в очереди"); +}); diff --git a/web/e2e/projects.spec.ts b/web/e2e/projects.spec.ts index 6a06741..3566d85 100644 --- a/web/e2e/projects.spec.ts +++ b/web/e2e/projects.spec.ts @@ -5,7 +5,7 @@ import { agentServeRefresh, login } from "./hub.ts"; test.describe.serial("проекты", () => { test("первый вход: проект AWPCA в адресе, на доске задача проекта", async ({ 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(); }); @@ -35,15 +35,15 @@ test.describe.serial("проекты", () => { }); 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 expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible(); 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 }) => { - await login(page, "/app/issues?project=NOPE"); + await login(page, "/app/jira/issues?project=NOPE"); await expect(page.getByText("проект NOPE не найден")).toBeVisible(); }); }); diff --git a/web/e2e/screens.spec.ts b/web/e2e/screens.spec.ts index f76c21c..7959b5c 100644 --- a/web/e2e/screens.spec.ts +++ b/web/e2e/screens.spec.ts @@ -1,7 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; import { mkdirSync } from "node:fs"; 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). // Результат — web/e2e-screens/<тема>-<ширина>-<экран>.png, в git не попадает. @@ -26,12 +26,20 @@ for (const theme of ["light", "dark"] as const) { }; test("login", async ({ page }) => { - await page.goto("/app/board"); + await page.goto("/app/jira/board"); await expect(page.getByLabel("Клиентский токен хаба")).toBeVisible(); await settle(page); 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 }) => { await login(page); 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 }) => { - await login(page, "/app/issues"); + await login(page, "/app/jira/issues"); await expect(page.locator("table.issue-table tbody tr").first()).toBeVisible(); await settle(page); await shot(page, "issues"); }); 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("combobox", { name: "Исполнитель" }).selectOption("Петров Пётр Петрович"); await expect(page).toHaveURL(/assignee=/); @@ -72,7 +80,7 @@ for (const theme of ["light", "dark"] as const) { }); 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 settle(page); 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` }); }); + 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 }) => { await login(page); - await page.getByRole("button", { name: "Обновить из Jira" }).click(); - await expect(page.getByRole("status")).toContainText(/пакет #\d+/); + await page.getByTestId("page-board").getByRole("button", { name: "Обновить из Jira", exact: true }).click(); + await expect(jiraEvent(page)).toContainText(/пакет #\d+/); await settle(page); await shot(page, "refresh"); await agentServeRefresh(); // не оставляем пакет в очереди тестового хаба - await expect(page.getByRole("status")).toContainText(/выполнен/, { timeout: 15_000 }); + await expect(jiraEvent(page)).toContainText(/выполнен/, { timeout: 15_000 }); }); }); } diff --git a/web/src/modules/jira/issues/issues.css b/web/src/modules/jira/issues/issues.css index 47a8f62..c946833 100644 --- a/web/src/modules/jira/issues/issues.css +++ b/web/src/modules/jira/issues/issues.css @@ -12,7 +12,7 @@ /* таблица прокручивается внутри своей панели на любой ширине (девять столбцов шире контейнера и на 1440 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}} .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)}