From e475a8afc1d2186c2d531becdaeea1f09a37fcdd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 13:25:44 +0400 Subject: [PATCH] =?UTF-8?q?test(web):=20e2e=20=D0=BE=D0=BA=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8,=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D0=B9=20=D1=85=D0=B0=D0=B1?= =?UTF-8?q?=20=D1=81=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D1=8F=D0=BC=D0=B8=20=D0=B8=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD=D0=B4=D0=B0=D0=BC=D0=B8;=20README=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раскладка карточки: в одну колонку (уже 900 px) колонки растягиваются на всю ширину (align-items: stretch), порог — 899.98px. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 6 +- README.md | 7 ++- web/e2e/board.spec.ts | 40 +++++++++--- web/e2e/geometry.spec.ts | 5 +- web/e2e/issues.spec.ts | 117 ++++++++++++++++++++++++++++++++--- web/scripts/seed-extras.json | 16 +++++ web/scripts/seed-hub.py | 18 +++++- web/src/issues/issues.css | 4 +- 8 files changed, 190 insertions(+), 23 deletions(-) create mode 100644 web/scripts/seed-extras.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0b3edc..8b8fe4d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -9,7 +9,7 @@ | `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib | | `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths` | | `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` | -| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры, снимок доски `board` | `db` | +| `mirror` | зеркало задач, связей и комментариев, `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` | | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | | `backup` | копии файла базы, ротация | `paths` | @@ -22,7 +22,9 @@ | `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` | `web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`) -и «Задачи» (`/app/issues`, `/app/issues/{key}`). Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку +и «Задачи» +(`/app/issues`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует +страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}` diff --git a/README.md b/README.md index 04d0d32..7b32379 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint= ## Веб-приложение -Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board`, задачи `/app/issues`, карточка `/app/issues/{key}`; `/` и +Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board`, задачи `/app/issues`, карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и `/links` перенаправляют на доску. Вход по клиентскому токену (`uv run jira-hub tokens`), токен хранится в браузере; `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini: @@ -174,6 +174,11 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub читает те же ключи `localStorage`, что прежняя страница. Разработка: `bash web/scripts/test-hub.sh` (тестовый хаб :8791 с синтетическими данными) и `cd web && npm run dev` (http://127.0.0.1:5173/app/). +Карточка задачи: в шапке — статус Jira, статус по факту, исполнитель, срок (просроченный — красным), спринт и эпик; +связи сгруппированы «Зависит от» / «Блокирует» / «Связана с» / «Прочие» с темой и статусом связанной задачи; +комментарии из Jira (агент v10+) новые сверху, «Скрыть» запоминается в браузере; заметки — режим чтения, «Править» +открывает форму. Комментарии появляются после первого обновления из Jira агентом v10. + ## Контракт команд Каждая команда описывает желаемое состояние одной задачи; агент читает, сравнивает и пишет только разницу, повтор с тем же diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index 7a8886a..bdd1976 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -53,13 +53,15 @@ test("«Обновить из Jira» с учебным агентом доход 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 expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/); - await expect(page.getByRole("heading", { name: /AURORA-9001/ })).toBeVisible(); + await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible(); + await expect(page.getByTestId("page-board")).toBeAttached(); + await expect(page.getByRole("link", { name: "Доска", exact: true })).toHaveClass(/active/); }); -test("M-3: прокрутка доски после возврата из карточки задачи", async ({ page }) => { +test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => { await page.setViewportSize({ width: 1600, height: 600 }); await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; }); @@ -72,9 +74,33 @@ test("M-3: прокрутка доски после возврата из кар return a?.closest(".card")?.dataset.key ?? null; }); expect(key).not.toBeNull(); - const link = page.locator(`.board .card[data-key="${key}"] a.open-card`); - await link.click(); - await expect(page).toHaveURL(/\/app\/issues\//); - await page.getByRole("link", { name: "Доска", exact: true }).click(); + let boardLoads = 0; + page.on("request", (r) => { if (new URL(r.url()).pathname === "/board") boardLoads++; }); + 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$/); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); + expect(boardLoads).toBe(0); // доска не перезагружалась + // ссылки меню вверху страницы: обычный click прокрутил бы доску к ним до перехода и затёр запомненное, поэтому dispatchEvent + await page.getByRole("link", { name: "Задачи", exact: true }).dispatchEvent("click"); + await expect(page).toHaveURL(/\/app\/issues$/); + await page.getByRole("link", { name: "Доска", exact: true }).dispatchEvent("click"); await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); }); + +test("заметка из окна уходит в хаб и доска под ним не пересоздаётся", async ({ page }) => { + // доска показывает вид работы по меткам Jira (fromHub), заметка work_kind в карточку доски не попадает: проверяем снимок /board + await page.locator('.board .card[data-key="AURORA-9002"] a.open-card').click(); + const d = page.getByRole("dialog"); + await d.getByRole("button", { name: "Править" }).click(); + await d.getByLabel("Вид работы").selectOption("тестирование"); + const snap = page.waitForResponse((r) => new URL(r.url()).pathname === "/board" && r.request().method() === "GET"); + await d.getByRole("button", { name: "Сохранить" }).click(); + const body = await (await snap).json(); + expect(body.issues.find((i: { key: string }) => i.key === "AURORA-9002").work_kind).toBe("тестирование"); + await expect(d.getByRole("button", { name: "Править" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("page-board")).toBeAttached(); + await expect(page.locator('.board .card[data-key="AURORA-9002"]')).toBeVisible(); +}); diff --git a/web/e2e/geometry.spec.ts b/web/e2e/geometry.spec.ts index 28231f5..87c09f4 100644 --- a/web/e2e/geometry.spec.ts +++ b/web/e2e/geometry.spec.ts @@ -66,12 +66,13 @@ test("смена спринта и исполнителя, убирающая к expect(errs).toEqual([]); }); -test("h1 на страницах доски, задач и карточки", async ({ page }) => { +test("h1 на страницах доски и задач, h2 в окне карточки", async ({ page }) => { await login(page); await expect(page.locator("h1")).toHaveText("Доска связей"); await page.getByRole("link", { name: "Задачи" }).click(); await expect(page.getByRole("heading", { level: 1, name: "Задачи" })).toBeVisible(); await page.getByRole("link", { name: "AURORA-9002" }).click(); - await expect(page.getByRole("heading", { level: 1, name: /AURORA-9002/ })).toBeVisible(); + // карточка — окно поверх списка: её заголовок h2 (тема задачи), h1 остаётся у страницы под окном + await expect(page.getByRole("dialog").getByRole("heading", { level: 2, name: "API свода" })).toBeVisible(); await expect(page.locator("h1")).toHaveCount(1); }); diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts index 1887060..13e0b18 100644 --- a/web/e2e/issues.spec.ts +++ b/web/e2e/issues.spec.ts @@ -1,15 +1,118 @@ import { expect, test } from "@playwright/test"; import { login } from "./hub.ts"; -test("фильтр → карточка → заметка сохраняется", async ({ page }) => { +test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => { await login(page, "/app/issues"); await page.getByLabel("Исполнитель").selectOption("Петров Пётр Петрович"); await expect(page).toHaveURL(/assignee=/); - await page.getByRole("link", { name: "AURORA-9002" }).click(); - const rest = page.getByLabel("Осталось"); - await rest.fill("e2e: дописать тесты"); - await page.getByRole("button", { name: "Сохранить" }).click(); - await expect(page.getByText(/сохранено .*web/)).toBeVisible(); + await page.getByRole("link", { name: "AURORA-9002", exact: true }).click(); + const d = page.getByRole("dialog"); + await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible(); + await d.getByRole("button", { name: "Править" }).click(); + await d.getByLabel("Осталось").fill("e2e: дописать тесты"); + await d.getByRole("button", { name: "Сохранить" }).click(); + await expect(d.getByText("e2e: дописать тесты")).toBeVisible(); await page.reload(); - await expect(page.getByLabel("Осталось")).toHaveValue("e2e: дописать тесты"); + 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\?assignee=/); + await expect(page.getByLabel("Исполнитель")).toHaveValue("Петров Пётр Петрович"); +}); + +test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 320 }); + await login(page, "/app/issues"); + await expect(page.locator("table.grid tbody tr")).toHaveCount(10); + const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; }); + expect(y).toBeGreaterThan(50); + await page.locator("table.grid tbody tr").last().getByRole("link").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + expect(await page.evaluate(() => window.scrollY)).toBe(y); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + expect(await page.evaluate(() => window.scrollY)).toBe(y); +}); + +test("переход по связи и «назад»; ✕ и щелчок по фону; после закрытия «назад» не открывает карточку", async ({ page }) => { + await login(page); // доска + await page.getByRole("link", { name: "Задачи", exact: true }).click(); + await page.getByRole("link", { name: "AURORA-9001", exact: true }).click(); + const d = page.getByRole("dialog"); + await d.getByRole("link", { name: "AURORA-9002", exact: true }).click(); + await expect(page).toHaveURL(/\/app\/issues\/AURORA-9002$/); + await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible(); + await page.goBack(); + await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); + await d.getByRole("link", { name: "AURORA-9002", exact: true }).click(); + await d.getByRole("button", { name: "Закрыть" }).click(); + await expect(page).toHaveURL(/\/app\/issues$/); + 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$/); + await expect(page.getByRole("dialog")).toHaveCount(0); +}); + +test("прямой адрес и перезагрузка: окно поверх списка, ✕ ведёт в список", async ({ page }) => { + await login(page, "/app/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$/); + await expect(page.getByRole("dialog")).toHaveCount(0); +}); + +test("карточка: описание свёрнуто, комментарии новые сверху и скрываются с запоминанием, команды свёрнуты", async ({ page }) => { + await login(page, "/app/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("[мост] служебный комментарий"); + await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible(); + await expect(d.locator("pre.description")).toHaveClass(/clamped/); // свёрнуто построчным обрезанием (текст в DOM остаётся) + await d.getByRole("button", { name: "развернуть" }).click(); + await expect(d.locator("pre.description")).not.toHaveClass(/clamped/); + await expect(d.getByText("Конец описания.")).toBeVisible(); + await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeHidden(); + 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); + await page.reload(); + await expect(page.getByRole("dialog").getByRole("button", { name: "Показать" })).toBeVisible(); + await expect(page.getByRole("dialog").getByText("Первый комментарий e2e")).toHaveCount(0); +}); + +test("несохранённая заметка: Esc спрашивает, «Остаться» оставляет окно, «Уйти» закрывает", async ({ page }) => { + await login(page, "/app/issues"); + await page.getByRole("link", { name: "AURORA-9003", exact: true }).click(); + const d = page.getByRole("dialog"); + await d.getByRole("button", { name: "Править" }).click(); + await d.getByLabel("Сделано").fill("черновик"); + await page.keyboard.press("Escape"); + await expect(d.getByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeVisible(); + await d.getByRole("button", { name: "Остаться" }).click(); + await expect(d.getByLabel("Сделано")).toHaveValue("черновик"); + await page.keyboard.press("Escape"); + await d.getByRole("button", { name: "Уйти" }).click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page).toHaveURL(/\/app\/issues$/); +}); + +test("узкий экран: окно на всю ширину, заметки выше связей, без горизонтальной прокрутки", async ({ page }) => { + await page.setViewportSize({ width: 600, height: 800 }); + await login(page, "/app/issues/AURORA-9001"); + const d = page.getByRole("dialog"); + await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); + expect((await d.boundingBox())!.width).toBeGreaterThan(590); + const notesY = (await d.getByRole("heading", { name: "Служебные заметки" }).boundingBox())!.y; + const linksY = (await d.getByRole("heading", { name: "Связи", exact: true }).boundingBox())!.y; + expect(notesY).toBeLessThan(linksY); + expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); }); diff --git a/web/scripts/seed-extras.json b/web/scripts/seed-extras.json new file mode 100644 index 0000000..38d6f01 --- /dev/null +++ b/web/scripts/seed-extras.json @@ -0,0 +1,16 @@ +{ + "issues": { + "AURORA-9001": { + "description": "Цель: схема свода для КФО.\nКонтекст: ТЗ 6.4.\nШаг 1 — таблицы.\nШаг 2 — связи.\nШаг 3 — индексы.\nШаг 4 — миграции.\nШаг 5 — проверки.\nШаг 6 — документация.\nШаг 7 — ревью.\nШаг 8 — выкатка.\nШаг 9 — приёмка.\nКонец описания.", + "comments": [ + {"id": "501", "author": "Петров Пётр Петрович", "created": "2026-10-13T10:00:00.000+0300", "updated": "2026-10-13T10:00:00.000+0300", "body": "Первый комментарий e2e\nвторая строка"}, + {"id": "502", "author": "Иванов Иван Иванович", "created": "2026-10-14T11:30:00.000+0300", "updated": "2026-10-14T12:00:00.000+0300", "body": "Схему согласовали с аналитиком."}, + {"id": "503", "author": null, "created": "2026-10-15T09:15:00.000+0300", "updated": "2026-10-15T09:15:00.000+0300", "body": "[мост] служебный комментарий"} + ] + } + }, + "commands": [ + {"type": "ensure_fields", "params": {"key": "AURORA-9001", "fields": {"duedate": "2026-10-20"}}, "status": "done"}, + {"type": "ensure_status", "params": {"key": "AURORA-9001", "status": "В работе"}, "status": "failed", "error": "нет перехода в «В работе»"} + ] +} diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py index a776854..767d93f 100644 --- a/web/scripts/seed-hub.py +++ b/web/scripts/seed-hub.py @@ -1,5 +1,6 @@ """Заполнить зеркало тестового хаба фикстурой доски и записать токены рядом с базой (для e2e и npm run dev). -Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет.""" +Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет. +Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом.""" import json import sys from pathlib import Path @@ -7,15 +8,28 @@ from pathlib import Path from jira_hub import db, mirror, paths, settings snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +extras_path = Path(__file__).with_name("seed-extras.json") +extras = json.loads(extras_path.read_text(encoding="utf-8")) if extras_path.exists() else {} +extra_issues = extras.get("issues", {}) conn = db.connect(paths.db_path()) settings.ensure_defaults(conn) -issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]} for i in snap["issues"]] +issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]} + | extra_issues.get(i["key"], {}) for i in snap["issues"]] links = [{"source_key": l["source"], "target_key": l["target"], "link_type": l["link_type"]} for l in snap["links"]] mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]]) for i in snap["issues"]: note = {k: i[k] for k in ("fact_status", "work_kind") if i.get(k)} if note: mirror.set_note(conn, i["key"], note, "seed") +if extras.get("commands"): # завершённые команды по задачам — для раздела «Команды моста» карточки + with db.transaction(conn): + batch = conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('Тестовые команды', 'seed', ?)", + (db.now(),)).lastrowid + for seq, c in enumerate(extras["commands"], 1): + conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at, error) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (batch, seq, c["type"], json.dumps(c["params"], ensure_ascii=False), c["status"], db.now(), + c.get("error"))) (paths.home() / "tokens.json").write_text(json.dumps(settings.tokens(conn)), encoding="utf-8") conn.close() print("зеркало:", len(issues), "задач,", len(links), "связей; токены —", paths.home() / "tokens.json") diff --git a/web/src/issues/issues.css b/web/src/issues/issues.css index 31383b2..263e1a6 100644 --- a/web/src/issues/issues.css +++ b/web/src/issues/issues.css @@ -22,8 +22,8 @@ .issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:24px;align-items:start} .issue .side{display:flex;flex-direction:column;gap:16px;min-width:0} .issue .main{min-width:0} -@media (max-width:900px){ - .issue .cols{display:flex;flex-direction:column;gap:16px} +@media (max-width:899.98px){ + .issue .cols{display:flex;flex-direction:column;align-items:stretch;gap:16px} .issue .side{display:contents} .issue .side .note{order:-1} .issue .side .other{order:1}