From 4160dd8b47e13b519d8ba69dc9c373732f78843f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 01:51:54 +0400 Subject: [PATCH] =?UTF-8?q?build(web):=20=D1=88=D1=80=D0=B8=D1=84=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B2=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B5,=20sourc?= =?UTF-8?q?e-map-js=201.2.2,=20=D0=BF=D0=BE=D1=80=D1=82=20e2e=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B9,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BE=D0=BB=D0=BE=D1=87=D0=BA=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=B3=D0=B5=D0=BE=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=B8=20(T2.2,?= =?UTF-8?q?=20O-1,=20T4.1=E2=80=93T4.5,=20T15.1,=20T15.2,=20T15.4,=20T15.6?= =?UTF-8?q?,=20#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/e2e/board.spec.ts | 7 +++++-- web/e2e/geometry.spec.ts | 20 ++++++++------------ web/e2e/hub.ts | 4 ++-- web/index.html | 1 - web/package-lock.json | 26 +++++++++++++++++++++++--- web/package.json | 5 +++++ web/playwright.config.ts | 8 +++++--- web/scripts/test-hub.sh | 3 +++ web/src/App.test.tsx | 11 ++++++++--- web/src/App.tsx | 2 -- web/src/main.tsx | 5 +++++ web/src/shell/theme.test.ts | 3 ++- 12 files changed, 66 insertions(+), 29 deletions(-) diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index 8ab28ef..fef8411 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { CW } from "../src/board/constants.ts"; import { agentServeRefresh, login } from "./hub.ts"; test.beforeEach(async ({ page }) => { await login(page); }); @@ -28,9 +29,9 @@ test("растяжка за уголок и возврат размера дво 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 expect.poll(async () => (await card.boundingBox())!.width).toBeGreaterThan(w0 + 60); await card.locator(".rsz").dblclick(); - expect(Math.round((await card.boundingBox())!.width)).toBe(240); + await expect.poll(async () => Math.round((await card.boundingBox())!.width)).toBe(CW); }); test("«по датам» — колонки дат, подписи и стрелки", async ({ page }) => { @@ -63,6 +64,8 @@ test("M-3: прокрутка доски после возврата из кар await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; }); expect(y).toBeGreaterThan(100); + // событие прокрутки приходит на следующем кадре: без ожидания страница уходит раньше, чем доска запомнит позицию + await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); // щелчок без прокрутки к элементу: иначе Playwright сам сдвинет страницу перед переходом await page.evaluate(() => (document.querySelector(".board .card[data-key] a.open-card") as HTMLElement).click()); await expect(page).toHaveURL(/\/app\/issues\//); diff --git a/web/e2e/geometry.spec.ts b/web/e2e/geometry.spec.ts index f828d31..28231f5 100644 --- a/web/e2e/geometry.spec.ts +++ b/web/e2e/geometry.spec.ts @@ -13,21 +13,17 @@ function watchErrors(page: Page): string[] { 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, - })); - }); + const rows = await page.evaluate(() => [...document.querySelectorAll(".board .card[data-key]")].map((e) => ({ + key: e.dataset.key!, h: e.offsetHeight, fits: e.scrollHeight <= e.clientHeight + 1, + }))); 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 + expect(r.fits, `${r.key}: содержимое не помещается`).toBe(true); + expect(r.h % 8, `${r.key}: высота не на сетке 8`).toBe(0); } - // слой измерения не зажат в 120px: хотя бы одна карточка разной высоты с другой или не равна 120 - expect(rows.some((r) => r.measured !== 120) || new Set(rows.map((r) => r.measured)).size > 1).toBe(true); + // на фикстуре hub-basic содержимое пяти карточек одной высоты, поэтому «разные высоты» не проверяем (отклонение от плана): + // высота не равна прежней фиксированной 120px + expect(rows.some((r) => r.h !== 120), "все карточки по 120px — высота не по содержимому").toBe(true); }); test("при открытой панели «Проблемы» полоса дат по ширине равна обёртке холста", async ({ page }) => { diff --git a/web/e2e/hub.ts b/web/e2e/hub.ts index 2f1f5c5..621bc7d 100644 --- a/web/e2e/hub.ts +++ b/web/e2e/hub.ts @@ -1,9 +1,9 @@ import { expect, type Page } from "@playwright/test"; import { readFileSync } from "node:fs"; -import { HUB_HOME } from "../playwright.config.ts"; +import { HUB_HOME, HUB_URL } 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"; +const BASE = HUB_URL; export async function login(page: Page, path = "/app/board") { await page.goto(path); diff --git a/web/index.html b/web/index.html index 3a30c3c..5f2db89 100644 --- a/web/index.html +++ b/web/index.html @@ -5,7 +5,6 @@ Хаб Jira -
diff --git a/web/package-lock.json b/web/package-lock.json index 858ee1c..30ac58f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -6,6 +6,8 @@ "": { "name": "jira-hub-web", "dependencies": { + "@fontsource/ibm-plex-mono": "5.3.0", + "@fontsource/ibm-plex-sans": "5.3.0", "@tanstack/react-query": "5.104.0", "react": "19.3.0", "react-dom": "19.3.0", @@ -663,6 +665,24 @@ } } }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3339,9 +3359,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/web/package.json b/web/package.json index ea29d51..5927f5e 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,8 @@ "golden": "node scripts/golden-from-links.ts" }, "dependencies": { + "@fontsource/ibm-plex-mono": "5.3.0", + "@fontsource/ibm-plex-sans": "5.3.0", "@tanstack/react-query": "5.104.0", "react": "19.3.0", "react-dom": "19.3.0", @@ -35,5 +37,8 @@ "typescript-eslint": "8.71.0", "vite": "8.3.1", "vitest": "5.0.2" + }, + "overrides": { + "source-map-js": "1.2.2" } } diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 7c05817..1d35840 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -2,17 +2,19 @@ import { defineConfig } from "@playwright/test"; import { resolve } from "node:path"; export const HUB_HOME = resolve(import.meta.dirname, ".e2e-hub"); +export const HUB_PORT = 8791; +export const HUB_URL = `http://127.0.0.1:${HUB_PORT}`; 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 } }, + use: { baseURL: HUB_URL, 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/", + url: `${HUB_URL}/app/`, reuseExistingServer: false, timeout: 120_000, - env: { JIRA_HUB_HOME: HUB_HOME, PORT: "8791" }, + env: { JIRA_HUB_HOME: HUB_HOME, PORT: String(HUB_PORT) }, }, }); diff --git a/web/scripts/test-hub.sh b/web/scripts/test-hub.sh index 879c516..9173969 100644 --- a/web/scripts/test-hub.sh +++ b/web/scripts/test-hub.sh @@ -13,6 +13,9 @@ case "$JIRA_HUB_HOME" in "$WEB/.e2e-hub"|"$WEB/.e2e-hub/"*) ;; *) echo "отказ: JIRA_HUB_HOME=$JIRA_HUB_HOME вне $WEB/.e2e-hub, очистка не выполняется" >&2; exit 2;; esac +if lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then + echo "отказ: порт $PORT занят — остановите прежний тестовый хаб (lsof -iTCP:$PORT -sTCP:LISTEN)" >&2; exit 3 +fi rm -rf "$JIRA_HUB_HOME" cd "$ROOT" uv run jira-hub init --config tests/fixtures/config.example.json --force >/dev/null diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index f82b69c..fde4622 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,11 +1,11 @@ -import { screen } from "@testing-library/react"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createMemoryRouter } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; import { App } from "./App.tsx"; import { clearToken, getAuth, setToken } from "./auth/token.ts"; import { routes } from "./routes.tsx"; +import { Layout } from "./shell/Layout.tsx"; import { json, mockFetch } from "./test/fetchMock.ts"; const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); @@ -24,11 +24,16 @@ describe("оболочка", () => { await userEvent.click(screen.getByRole("link", { name: "Задачи" })); expect(router.state.location.pathname).toBe("/app/issues"); expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("link", { name: "Доска" })); + expect(router.state.location.pathname).toBe("/app/board"); + expect(await screen.findByTestId("page-board")).toBeInTheDocument(); }); it("неизвестный путь — «не найдено» со ссылками", async () => { render(); expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "доска" })).toHaveAttribute("href", "/app/board"); + expect(screen.getByRole("link", { name: "задачи" })).toHaveAttribute("href", "/app/issues"); }); it("«Сменить токен» показывает экран входа", async () => { @@ -40,7 +45,7 @@ describe("оболочка", () => { it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => { const Boom = () => { throw new Error("сломалось"); }; - const router = createMemoryRouter([{ path: "/", element: , children: [{ path: "x", element: }] }], + const router = createMemoryRouter([{ path: "/", element: , children: [{ path: "x", element: }] }], { initialEntries: ["/x"] }); render(); expect(await screen.findByText(/сломалось/)).toBeInTheDocument(); diff --git a/web/src/App.tsx b/web/src/App.tsx index 1ad4257..02634e5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,7 +4,6 @@ import { createBrowserRouter, RouterProvider } from "react-router"; import { makeQueryClient } from "./api/queryClient.ts"; import { AuthGate } from "./auth/AuthGate.tsx"; import { routes } from "./routes.tsx"; -import { Layout } from "./shell/Layout.tsx"; import "./styles/theme.css"; import "./styles/base.css"; import "./styles/controls.css"; @@ -20,4 +19,3 @@ export function App({ router }: { router?: Router }) { ); } -App.Layout = Layout; diff --git a/web/src/main.tsx b/web/src/main.tsx index 2fd256e..16cb33f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,5 +1,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +// шрифты входят в сборку: приложение не ходит в интернет (T2.2) +import "@fontsource/ibm-plex-sans/400.css"; +import "@fontsource/ibm-plex-sans/500.css"; +import "@fontsource/ibm-plex-sans/600.css"; +import "@fontsource/ibm-plex-mono/500.css"; import { App } from "./App.tsx"; import { applyTheme, readTheme } from "./shell/theme.ts"; diff --git a/web/src/shell/theme.test.ts b/web/src/shell/theme.test.ts index 5ffd7ed..ed437f6 100644 --- a/web/src/shell/theme.test.ts +++ b/web/src/shell/theme.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { applyTheme, readTheme, saveTheme } from "./theme.ts"; describe("тема", () => { + afterEach(() => document.documentElement.removeAttribute("data-theme")); it("читает graph-theme, неизвестное значение — системная", () => { localStorage.setItem("graph-theme", "dark"); expect(readTheme()).toBe("dark");