build(web): шрифты в сборке, source-map-js 1.2.2, порт e2e одной константой, тесты оболочки и геометрии (T2.2, O-1, T4.1–T4.5, T15.1, T15.2, T15.4, T15.6, #29)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 01:51:54 +04:00
parent dd95e85a25
commit 4160dd8b47
12 changed files with 66 additions and 29 deletions

View File

@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { CW } from "../src/board/constants.ts";
import { agentServeRefresh, login } from "./hub.ts"; import { agentServeRefresh, login } from "./hub.ts";
test.beforeEach(async ({ page }) => { await login(page); }); test.beforeEach(async ({ page }) => { await login(page); });
@ -28,9 +29,9 @@ test("растяжка за уголок и возврат размера дво
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(rsz.x + 82, rsz.y + 42, { steps: 6 }); await page.mouse.move(rsz.x + 82, rsz.y + 42, { steps: 6 });
await page.mouse.up(); 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(); 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 }) => { test("«по датам» — колонки дат, подписи и стрелки", async ({ page }) => {
@ -63,6 +64,8 @@ test("M-3: прокрутка доски после возврата из кар
await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; }); const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
expect(y).toBeGreaterThan(100); expect(y).toBeGreaterThan(100);
// событие прокрутки приходит на следующем кадре: без ожидания страница уходит раньше, чем доска запомнит позицию
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
// щелчок без прокрутки к элементу: иначе Playwright сам сдвинет страницу перед переходом // щелчок без прокрутки к элементу: иначе Playwright сам сдвинет страницу перед переходом
await page.evaluate(() => (document.querySelector(".board .card[data-key] a.open-card") as HTMLElement).click()); await page.evaluate(() => (document.querySelector(".board .card[data-key] a.open-card") as HTMLElement).click());
await expect(page).toHaveURL(/\/app\/issues\//); await expect(page).toHaveURL(/\/app\/issues\//);

View File

@ -13,21 +13,17 @@ function watchErrors(page: Page): string[] {
test("высота карточек берётся по содержимому из слоя измерения, а не 120px", async ({ page }) => { test("высота карточек берётся по содержимому из слоя измерения, а не 120px", async ({ page }) => {
await login(page); await login(page);
await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
const rows = await page.evaluate(() => { const rows = await page.evaluate(() => [...document.querySelectorAll<HTMLElement>(".board .card[data-key]")].map((e) => ({
const m = new Map<string, number>(); key: e.dataset.key!, h: e.offsetHeight, fits: e.scrollHeight <= e.clientHeight + 1,
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); expect(rows.length).toBeGreaterThan(0);
for (const r of rows) { for (const r of rows) {
expect(r.measured, r.key).toBeGreaterThan(0); expect(r.fits, `${r.key}: содержимое не помещается`).toBe(true);
expect(r.h, r.key).toBe(Math.max(80, Math.ceil(r.measured / 8) * 8)); // measuredHeight: сетка 8, не меньше MINH expect(r.h % 8, `${r.key}: высота не на сетке 8`).toBe(0);
} }
// слой измерения не зажат в 120px: хотя бы одна карточка разной высоты с другой или не равна 120 // на фикстуре hub-basic содержимое пяти карточек одной высоты, поэтому «разные высоты» не проверяем (отклонение от плана):
expect(rows.some((r) => r.measured !== 120) || new Set(rows.map((r) => r.measured)).size > 1).toBe(true); // высота не равна прежней фиксированной 120px
expect(rows.some((r) => r.h !== 120), "все карточки по 120px — высота не по содержимому").toBe(true);
}); });
test("при открытой панели «Проблемы» полоса дат по ширине равна обёртке холста", async ({ page }) => { test("при открытой панели «Проблемы» полоса дат по ширине равна обёртке холста", async ({ page }) => {

View File

@ -1,9 +1,9 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { readFileSync } from "node:fs"; 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 }; 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") { export async function login(page: Page, path = "/app/board") {
await page.goto(path); await page.goto(path);

View File

@ -5,7 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Хаб Jira</title> <title>Хаб Jira</title>
<link rel="icon" href="data:,"> <link rel="icon" href="data:,">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@500&display=swap">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

26
web/package-lock.json generated
View File

@ -6,6 +6,8 @@
"": { "": {
"name": "jira-hub-web", "name": "jira-hub-web",
"dependencies": { "dependencies": {
"@fontsource/ibm-plex-mono": "5.3.0",
"@fontsource/ibm-plex-sans": "5.3.0",
"@tanstack/react-query": "5.104.0", "@tanstack/react-query": "5.104.0",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "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": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@ -3339,9 +3359,9 @@
} }
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {

View File

@ -11,6 +11,8 @@
"golden": "node scripts/golden-from-links.ts" "golden": "node scripts/golden-from-links.ts"
}, },
"dependencies": { "dependencies": {
"@fontsource/ibm-plex-mono": "5.3.0",
"@fontsource/ibm-plex-sans": "5.3.0",
"@tanstack/react-query": "5.104.0", "@tanstack/react-query": "5.104.0",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
@ -35,5 +37,8 @@
"typescript-eslint": "8.71.0", "typescript-eslint": "8.71.0",
"vite": "8.3.1", "vite": "8.3.1",
"vitest": "5.0.2" "vitest": "5.0.2"
},
"overrides": {
"source-map-js": "1.2.2"
} }
} }

View File

@ -2,17 +2,19 @@ import { defineConfig } from "@playwright/test";
import { resolve } from "node:path"; import { resolve } from "node:path";
export const HUB_HOME = resolve(import.meta.dirname, ".e2e-hub"); 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({ export default defineConfig({
testDir: "e2e", testDir: "e2e",
workers: 1, workers: 1,
timeout: 60_000, 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: { webServer: {
command: "npm run build && bash scripts/test-hub.sh", command: "npm run build && bash scripts/test-hub.sh",
url: "http://127.0.0.1:8791/app/", url: `${HUB_URL}/app/`,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 120_000, timeout: 120_000,
env: { JIRA_HUB_HOME: HUB_HOME, PORT: "8791" }, env: { JIRA_HUB_HOME: HUB_HOME, PORT: String(HUB_PORT) },
}, },
}); });

View File

@ -13,6 +13,9 @@ case "$JIRA_HUB_HOME" in
"$WEB/.e2e-hub"|"$WEB/.e2e-hub/"*) ;; "$WEB/.e2e-hub"|"$WEB/.e2e-hub/"*) ;;
*) echo "отказ: JIRA_HUB_HOME=$JIRA_HUB_HOME вне $WEB/.e2e-hub, очистка не выполняется" >&2; exit 2;; *) echo "отказ: JIRA_HUB_HOME=$JIRA_HUB_HOME вне $WEB/.e2e-hub, очистка не выполняется" >&2; exit 2;;
esac 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" rm -rf "$JIRA_HUB_HOME"
cd "$ROOT" cd "$ROOT"
uv run jira-hub init --config tests/fixtures/config.example.json --force >/dev/null uv run jira-hub init --config tests/fixtures/config.example.json --force >/dev/null

View File

@ -1,11 +1,11 @@
import { screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { createMemoryRouter } from "react-router"; import { createMemoryRouter } from "react-router";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { App } from "./App.tsx"; import { App } from "./App.tsx";
import { clearToken, getAuth, setToken } from "./auth/token.ts"; import { clearToken, getAuth, setToken } from "./auth/token.ts";
import { routes } from "./routes.tsx"; import { routes } from "./routes.tsx";
import { Layout } from "./shell/Layout.tsx";
import { json, mockFetch } from "./test/fetchMock.ts"; import { json, mockFetch } from "./test/fetchMock.ts";
const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
@ -24,11 +24,16 @@ describe("оболочка", () => {
await userEvent.click(screen.getByRole("link", { name: "Задачи" })); await userEvent.click(screen.getByRole("link", { name: "Задачи" }));
expect(router.state.location.pathname).toBe("/app/issues"); expect(router.state.location.pathname).toBe("/app/issues");
expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); 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 () => { it("неизвестный путь — «не найдено» со ссылками", async () => {
render(<App router={at("/app/nope")} />); render(<App router={at("/app/nope")} />);
expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); 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 () => { it("«Сменить токен» показывает экран входа", async () => {
@ -40,7 +45,7 @@ describe("оболочка", () => {
it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => { it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => {
const Boom = () => { throw new Error("сломалось"); }; const Boom = () => { throw new Error("сломалось"); };
const router = createMemoryRouter([{ path: "/", element: <App.Layout />, children: [{ path: "x", element: <Boom /> }] }], const router = createMemoryRouter([{ path: "/", element: <Layout />, children: [{ path: "x", element: <Boom /> }] }],
{ initialEntries: ["/x"] }); { initialEntries: ["/x"] });
render(<App router={router} />); render(<App router={router} />);
expect(await screen.findByText(/сломалось/)).toBeInTheDocument(); expect(await screen.findByText(/сломалось/)).toBeInTheDocument();

View File

@ -4,7 +4,6 @@ import { createBrowserRouter, RouterProvider } from "react-router";
import { makeQueryClient } from "./api/queryClient.ts"; import { makeQueryClient } from "./api/queryClient.ts";
import { AuthGate } from "./auth/AuthGate.tsx"; import { AuthGate } from "./auth/AuthGate.tsx";
import { routes } from "./routes.tsx"; import { routes } from "./routes.tsx";
import { Layout } from "./shell/Layout.tsx";
import "./styles/theme.css"; import "./styles/theme.css";
import "./styles/base.css"; import "./styles/base.css";
import "./styles/controls.css"; import "./styles/controls.css";
@ -20,4 +19,3 @@ export function App({ router }: { router?: Router }) {
</QueryClientProvider> </QueryClientProvider>
); );
} }
App.Layout = Layout;

View File

@ -1,5 +1,10 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; 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 { App } from "./App.tsx";
import { applyTheme, readTheme } from "./shell/theme.ts"; import { applyTheme, readTheme } from "./shell/theme.ts";

View File

@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { applyTheme, readTheme, saveTheme } from "./theme.ts"; import { applyTheme, readTheme, saveTheme } from "./theme.ts";
describe("тема", () => { describe("тема", () => {
afterEach(() => document.documentElement.removeAttribute("data-theme"));
it("читает graph-theme, неизвестное значение — системная", () => { it("читает graph-theme, неизвестное значение — системная", () => {
localStorage.setItem("graph-theme", "dark"); localStorage.setItem("graph-theme", "dark");
expect(readTheme()).toBe("dark"); expect(readTheme()).toBe("dark");