test(web): тестовый хаб и e2e — вход, доска, задачи, сверка со старой страницей (#21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 23:39:44 +04:00
parent d6fe046efa
commit 28131a7d56
11 changed files with 288 additions and 1 deletions

1
.gitignore vendored
View File

@ -15,3 +15,4 @@ __pycache__/
src/jira_hub/web/app/
web/test-results/
web/playwright-report/
web/.e2e-hub/

59
web/e2e/board.spec.ts Normal file
View File

@ -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();
});

33
web/e2e/compare.spec.ts Normal file
View File

@ -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<string, Box>);
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");
}
});

81
web/e2e/geometry.spec.ts Normal file
View File

@ -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<string, number>();
document.querySelectorAll<HTMLElement>(".measure .card[data-key]").forEach((e) => m.set(e.dataset.key!, e.offsetHeight));
return [...document.querySelectorAll<HTMLElement>(".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);
});

28
web/e2e/hub.ts Normal file
View File

@ -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;
}

15
web/e2e/issues.spec.ts Normal file
View File

@ -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: дописать тесты");
});

17
web/e2e/login.spec.ts Normal file
View File

@ -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$/);
});

View File

@ -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,
{

18
web/playwright.config.ts Normal file
View File

@ -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" },
},
});

21
web/scripts/seed-hub.py Normal file
View File

@ -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")

14
web/scripts/test-hub.sh Normal file
View File

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