diff --git a/.gitignore b/.gitignore index 1cf991c..7f60f8e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ __pycache__/ src/jira_hub/web/app/ web/test-results/ web/playwright-report/ +web/.e2e-hub/ diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts new file mode 100644 index 0000000..5519d69 --- /dev/null +++ b/web/e2e/board.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test"; +import { agentServeRefresh, login } from "./hub.ts"; + +test.beforeEach(async ({ page }) => { await login(page); }); + +test("перетаскивание за рамку двигает карточку и переживает перезагрузку", async ({ page }) => { + const card = page.locator('.board .card[data-key="AURORA-9001"]'); + const before = await card.boundingBox(); + const grip = card.locator(".grip.t"); + const g = (await grip.boundingBox())!; + await page.mouse.move(g.x + g.width / 2, g.y + g.height / 2); + await page.mouse.down(); + await page.mouse.move(g.x + g.width / 2 + 160, g.y + g.height / 2 + 40, { steps: 8 }); + await page.mouse.up(); + const after = (await card.boundingBox())!; + expect(Math.round(after.x - before!.x)).toBeGreaterThan(100); + await page.reload(); + const reloaded = (await page.locator('.board .card[data-key="AURORA-9001"]').boundingBox())!; + expect(Math.abs(reloaded.x - after.x)).toBeLessThan(2); + expect(Math.abs(reloaded.y - after.y)).toBeLessThan(2); +}); + +test("растяжка за уголок и возврат размера двойным щелчком", async ({ page }) => { + const card = page.locator('.board .card[data-key="AURORA-9002"]'); + const rsz = (await card.locator(".rsz").boundingBox())!; + const w0 = (await card.boundingBox())!.width; + await page.mouse.move(rsz.x + 2, rsz.y + 2); + await page.mouse.down(); + await page.mouse.move(rsz.x + 82, rsz.y + 42, { steps: 6 }); + await page.mouse.up(); + expect((await card.boundingBox())!.width).toBeGreaterThan(w0 + 60); + await card.locator(".rsz").dblclick(); + expect(Math.round((await card.boundingBox())!.width)).toBe(240); +}); + +test("«по датам» — колонки дат, подписи и стрелки", async ({ page }) => { + await page.getByRole("button", { name: "▥ по датам" }).click(); + await expect(page.locator(".dcol").first()).toBeVisible(); + await expect(page.locator(".dates-track .dlabel").first()).toBeVisible(); + await expect(page.locator("svg.edges path")).not.toHaveCount(0); +}); + +test("«Обновить из Jira» с учебным агентом доходит до конца", async ({ page }) => { + const btn = page.getByRole("button", { name: "Обновить из Jira" }); + await btn.click(); + await expect(btn).toBeDisabled(); + await expect(page.getByRole("status")).toContainText(/пакет #\d+/); + await agentServeRefresh(); + await expect(page.getByRole("status")).toContainText(/пакет #\d+ выполнен/, { timeout: 15_000 }); + await expect(btn).toBeEnabled(); + 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.getByRole("heading", { name: /AURORA-9001/ })).toBeVisible(); +}); diff --git a/web/e2e/compare.spec.ts b/web/e2e/compare.spec.ts new file mode 100644 index 0000000..a1a903e --- /dev/null +++ b/web/e2e/compare.spec.ts @@ -0,0 +1,33 @@ +import { expect, test, type Page } from "@playwright/test"; +import { login } from "./hub.ts"; + +type Box = { left: string; top: string; width: string; height: string }; +const boxes = (page: Page) => page.locator(".board .card").evaluateAll((els) => + Object.fromEntries(els.map((e) => { + const el = e as HTMLElement; + return [el.querySelector("a.key")?.textContent ?? "", { left: el.style.left, top: el.style.top, width: el.style.width, height: el.style.height }]; + })) as Record); +const lanes = (page: Page) => page.locator(".board .lane-name").allTextContents(); + +test("раскладка новой доски совпадает со старой страницей", async ({ page }) => { + await login(page); // токен в localStorage общий для /app и /links + for (const mode of ["обычная", "по датам"]) { + if (mode === "по датам") await page.getByRole("button", { name: "▥ по датам" }).click(); + await expect(page.locator(".board .card").first()).toBeVisible(); + const fresh = { boxes: await boxes(page), lanes: await lanes(page), edges: await page.locator("svg.edges path").count() }; + await page.screenshot({ path: `test-results/compare/new-${mode}.png`, fullPage: true }); + await page.goto("/links"); + await expect(page.locator(".board .card").first()).toBeVisible(); + const old = { boxes: await boxes(page), lanes: await lanes(page), edges: await page.locator("#edges path").count() }; + await page.screenshot({ path: `test-results/compare/old-${mode}.png`, fullPage: true }); + expect(fresh.lanes).toEqual(old.lanes); + expect(fresh.edges).toBe(old.edges); + expect(Object.keys(fresh.boxes).sort()).toEqual(Object.keys(old.boxes).sort()); + for (const k of Object.keys(old.boxes)) { + for (const p of ["left", "top", "width", "height"] as const) { + expect(Math.abs(parseFloat(fresh.boxes[k][p]) - parseFloat(old.boxes[k][p])), `${mode} ${k}.${p}`).toBeLessThanOrEqual(1); + } + } + await page.goto("/app/board"); + } +}); diff --git a/web/e2e/geometry.spec.ts b/web/e2e/geometry.spec.ts new file mode 100644 index 0000000..f828d31 --- /dev/null +++ b/web/e2e/geometry.spec.ts @@ -0,0 +1,81 @@ +import { expect, test, type Page } from "@playwright/test"; +import { login } from "./hub.ts"; + +// то, что в jsdom не проверить: реальная геометрия, ширины и поведение при смене фильтров + +function watchErrors(page: Page): string[] { + const errs: string[] = []; + page.on("pageerror", (e) => errs.push("pageerror: " + e.message)); + page.on("console", (m) => { if (m.type() === "error") errs.push("console: " + m.text()); }); + return errs; +} + +test("высота карточек берётся по содержимому из слоя измерения, а не 120px", async ({ page }) => { + await login(page); + await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); + const rows = await page.evaluate(() => { + const m = new Map(); + document.querySelectorAll(".measure .card[data-key]").forEach((e) => m.set(e.dataset.key!, e.offsetHeight)); + return [...document.querySelectorAll(".board .card[data-key]")].map((e) => ({ + key: e.dataset.key!, h: parseFloat(e.style.height), measured: m.get(e.dataset.key!) ?? -1, + measureStyleHeight: getComputedStyle(document.querySelector(`.measure .card[data-key="${e.dataset.key}"]`)!).height, + })); + }); + expect(rows.length).toBeGreaterThan(0); + for (const r of rows) { + expect(r.measured, r.key).toBeGreaterThan(0); + expect(r.h, r.key).toBe(Math.max(80, Math.ceil(r.measured / 8) * 8)); // measuredHeight: сетка 8, не меньше MINH + } + // слой измерения не зажат в 120px: хотя бы одна карточка разной высоты с другой или не равна 120 + expect(rows.some((r) => r.measured !== 120) || new Set(rows.map((r) => r.measured)).size > 1).toBe(true); +}); + +test("при открытой панели «Проблемы» полоса дат по ширине равна обёртке холста", async ({ page }) => { + await login(page); + await page.getByRole("button", { name: "▥ по датам" }).click(); + await expect(page.locator(".dates-in")).toBeVisible(); + for (const open of [false, true]) { + if (open) { + await page.getByRole("button", { name: /^Проблемы/ }).click(); + await expect(page.locator(".drawer")).toBeVisible(); + } + await expect.poll(async () => { + const a = (await page.locator(".dates-in").boundingBox())!.width, b = (await page.locator(".stage .wrap").boundingBox())!.width; + return Math.abs(a - b); + }, `панель ${open ? "открыта" : "закрыта"}`).toBeLessThanOrEqual(1); + } +}); + +test("смена спринта и исполнителя, убирающая карточки, не даёт ошибок и белого экрана", async ({ page }) => { + const errs = watchErrors(page); + await login(page); + await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); + const total = await page.locator(".board .card[data-key]").count(); + const dd = (i: number) => page.locator(".bar .dd-btn").nth(i); // 0 — спринты, 1 — исполнители + for (const [i, name] of [[0, "Спринт 3"], [1, "Сидорова"]] as const) { + await dd(i).click(); + await page.getByRole("button", { name: "Снять выбор" }).click(); + await page.locator(".dd-item", { hasText: name }).first().locator("input").check(); + await page.keyboard.press("Escape"); + await expect(page.locator("h1")).toHaveText("Доска связей"); + await expect(page.locator(".top")).toBeVisible(); + await expect(page.locator(".board")).toBeVisible(); + await expect.poll(() => page.locator(".board .card[data-key]:not(.dim)").count(), `фильтр «${name}»`).toBeLessThan(total); + await dd(i).click(); + await page.getByRole("button", { name: "Снять выбор" }).click(); + await page.keyboard.press("Escape"); + } + await page.getByRole("button", { name: "▥ по датам" }).click(); + await expect(page.locator(".top")).toBeVisible(); + expect(errs).toEqual([]); +}); + +test("h1 на страницах доски, задач и карточки", 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(); + await expect(page.locator("h1")).toHaveCount(1); +}); diff --git a/web/e2e/hub.ts b/web/e2e/hub.ts new file mode 100644 index 0000000..2f1f5c5 --- /dev/null +++ b/web/e2e/hub.ts @@ -0,0 +1,28 @@ +import { expect, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { HUB_HOME } from "../playwright.config.ts"; + +export const tokens = () => JSON.parse(readFileSync(`${HUB_HOME}/tokens.json`, "utf8")) as { agent: string; client: string }; +const BASE = "http://127.0.0.1:8791"; + +export async function login(page: Page, path = "/app/board") { + await page.goto(path); + await page.getByLabel("Клиентский токен хаба").fill(tokens().client); + await page.getByRole("button", { name: "Войти" }).click(); + await expect(page.getByRole("link", { name: "Доска" })).toBeVisible(); +} + +async function call(path: string, body: unknown) { + const r = await fetch(BASE + path, { method: "POST", headers: { Authorization: "Bearer " + tokens().agent, "Content-Type": "application/json" }, body: JSON.stringify(body) }); + expect(r.ok, `${path} → ${r.status}`).toBe(true); + return r.json(); +} + +// учебный агент: взять команду refresh, загрузить состояние зеркала как есть и отчитаться done +export async function agentServeRefresh() { + const { command } = await call("/agent/claim", { agent_version: 99, settings_version: null, last_jira_call_at: null }); + expect(command?.type).toBe("refresh"); + await call("/agent/state", { command_id: command.id, issues: [], links: [], neighbor_keys: [] }); + await call(`/agent/commands/${command.id}/result`, { status: "done", result: { issues: 0 }, jira_calls: 1, rate_limited: 0 }); + return command.id as number; +} diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts new file mode 100644 index 0000000..1887060 --- /dev/null +++ b/web/e2e/issues.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; +import { login } from "./hub.ts"; + +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.reload(); + await expect(page.getByLabel("Осталось")).toHaveValue("e2e: дописать тесты"); +}); diff --git a/web/e2e/login.spec.ts b/web/e2e/login.spec.ts new file mode 100644 index 0000000..5bc9040 --- /dev/null +++ b/web/e2e/login.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from "@playwright/test"; +import { login } from "./hub.ts"; + +test("неверный токен — пояснение; верный — доска", async ({ page }) => { + await page.goto("/app/board"); + await page.getByLabel("Клиентский токен хаба").fill("wrong-token"); + await page.getByRole("button", { name: "Войти" }).click(); + await expect(page.getByText(/Токен не подошёл/)).toBeVisible(); + await login(page); + await expect(page.locator(".card[data-key]").first()).toBeVisible(); +}); + +test("/ и /app ведут на доску", async ({ page }) => { + await login(page); + await page.goto("/app"); + await expect(page).toHaveURL(/\/app\/board$/); +}); diff --git a/web/eslint.config.js b/web/eslint.config.js index 2aa3b92..d5f394e 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -4,7 +4,7 @@ import globals from "globals"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["node_modules", "playwright-report", "test-results"] }, + { ignores: ["node_modules", "playwright-report", "test-results", ".e2e-hub"] }, js.configs.recommended, ...tseslint.configs.recommended, { diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..7c05817 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; +import { resolve } from "node:path"; + +export const HUB_HOME = resolve(import.meta.dirname, ".e2e-hub"); + +export default defineConfig({ + testDir: "e2e", + workers: 1, + timeout: 60_000, + use: { baseURL: "http://127.0.0.1:8791", trace: "retain-on-failure", viewport: { width: 1600, height: 1000 } }, + webServer: { + command: "npm run build && bash scripts/test-hub.sh", + url: "http://127.0.0.1:8791/app/", + reuseExistingServer: false, + timeout: 120_000, + env: { JIRA_HUB_HOME: HUB_HOME, PORT: "8791" }, + }, +}); diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py new file mode 100644 index 0000000..a776854 --- /dev/null +++ b/web/scripts/seed-hub.py @@ -0,0 +1,21 @@ +"""Заполнить зеркало тестового хаба фикстурой доски и записать токены рядом с базой (для e2e и npm run dev). +Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет.""" +import json +import sys +from pathlib import Path + +from jira_hub import db, mirror, paths, settings + +snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +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"]] +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") +(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/scripts/test-hub.sh b/web/scripts/test-hub.sh new file mode 100644 index 0000000..f03713b --- /dev/null +++ b/web/scripts/test-hub.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Тестовый хаб для разработки фронта и e2e: свой JIRA_HUB_HOME (по умолчанию web/.e2e-hub, очищается), порт 8791, +# зеркало — фикстура hub-basic. Живой хаб (:8790, ~/.local/share/jira-hub) не трогается. +set -euo pipefail +WEB=$(cd "$(dirname "$0")/.." && pwd) +ROOT=$(cd "$WEB/.." && pwd) +export JIRA_HUB_HOME="${JIRA_HUB_HOME:-$WEB/.e2e-hub}" +PORT="${PORT:-8791}" +case "$JIRA_HUB_HOME" in "$HOME/.local/share/jira-hub"*) echo "отказ: это каталог живого хаба" >&2; exit 2;; esac +rm -rf "$JIRA_HUB_HOME" +cd "$ROOT" +uv run jira-hub init --config tests/fixtures/config.example.json --force >/dev/null +uv run python web/scripts/seed-hub.py web/src/board/__fixtures__/hub-basic.board.json +exec uv run jira-hub serve --host 127.0.0.1 --port "$PORT"