test(web): тестовый хаб и e2e — вход, доска, задачи, сверка со старой страницей (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d6fe046efa
commit
28131a7d56
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
59
web/e2e/board.spec.ts
Normal 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
33
web/e2e/compare.spec.ts
Normal 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
81
web/e2e/geometry.spec.ts
Normal 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
28
web/e2e/hub.ts
Normal 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
15
web/e2e/issues.spec.ts
Normal 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
17
web/e2e/login.spec.ts
Normal 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$/);
|
||||
});
|
||||
@ -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
18
web/playwright.config.ts
Normal 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
21
web/scripts/seed-hub.py
Normal 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
14
web/scripts/test-hub.sh
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user