From bb3ddd1770b4de07c734cbf60e52ed2a4b228b8b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 02:40:33 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B2=D0=BE=D0=BB=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC?= =?UTF-8?q?=D1=83=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=E2=80=94=20I-2=20fro?= =?UTF-8?q?mFile=20=D0=BF=D1=80=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=B8=D0=B7=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D0=B0,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B,=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8,?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B0=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-2, M-4..M-8, M-11, M-13, M-16, M-18, T7.3, M-1..M-3, M-17. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- openspec/changes/web-app-followups/design.md | 12 +++--- .../changes/web-app-followups/proposal.md | 2 +- .../web-app-followups/specs/web-app/spec.md | 2 +- src/jira_hub/api.py | 2 +- web/playwright.config.ts | 2 + web/scripts/test-hub.sh | 2 +- web/src/api/queries.test.ts | 37 +++++++++++++++++++ web/src/api/queryClient.ts | 2 +- web/src/auth/LoginScreen.test.tsx | 4 +- web/src/board/analysis/analysis.test.ts | 11 ++++++ web/src/board/analysis/facets.ts | 6 ++- web/src/board/components/BoardCanvas.test.tsx | 7 ++-- web/src/board/components/MultiSelect.tsx | 2 +- web/src/board/useBoardSource.test.tsx | 16 +++++++- web/src/board/useBoardSource.ts | 3 +- web/src/issues/IssuesPage.test.tsx | 7 +++- web/vite.config.ts | 2 +- 18 files changed, 94 insertions(+), 27 deletions(-) create mode 100644 web/src/api/queries.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index ce4a564..16ef7bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # jira-hub ## Что это -Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущий change — `openspec/changes/web-app-followups/`. +Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; активные change — `openspec/changes/` (кроме `archive/`). ## Стек и ограничения - Веб-приложение — `web/` (React 19 + TypeScript + Vite, TanStack Query, Vitest, Playwright; Node 24, npm); сборка в `src/jira_hub/web/app/` не в git. diff --git a/openspec/changes/web-app-followups/design.md b/openspec/changes/web-app-followups/design.md index 2bd6eaf..7703cf3 100644 --- a/openspec/changes/web-app-followups/design.md +++ b/openspec/changes/web-app-followups/design.md @@ -25,9 +25,9 @@ jira-hub#29 (46 пунктов, маркеры сохраняются из PR #2 ## Decisions -1. **Прокрутка доски (M-3).** При размонтировании `BoardPage` запоминает `window.scrollX/scrollY` (и `scrollLeft` - горизонтальной обёртки холста, если она прокручивается сама) в переменной модуля; при монтировании, после первой - отрисовки холста с данными, восстанавливает. Перезагрузка страницы открывает доску сверху, как сейчас. Состояние +1. **Прокрутка доски (M-3).** `BoardCanvas` запоминает `window.scrollX/scrollY` (и `scrollLeft` + горизонтальной обёртки `.wrap`, если она прокручивается сама) в переменной модуля по событиям `scroll` окна и `.wrap`; + при монтировании восстанавливает в `useLayoutEffect` после `placed` (первой отрисовки холста с данными). Перезагрузка страницы открывает доску сверху, как сейчас. Состояние источника не поднимается выше страницы — rejected часть M-3 (решение владельца). 2. **Строка состояния при идущем пакете (T10.3).** `reload()` и `loadFile()` не сбрасывают событие `batch`, пока `active !== null`; сообщения о файле (`file-error`, `json-applied`) по-прежнему заменяют его (одно событие, M-5 #20), а @@ -41,7 +41,7 @@ jira-hub#29 (46 пунктов, маркеры сохраняются из PR #2 снимка, которое вызвал сам `finish()` (флаг «снимок после своего пакета»), — без лишнего `GET /batches` (T10.1). 5. **Токен в ошибке (T3.5).** `call()` кладёт в `AuthError` токен, с которым ушёл запрос; `clearToken` вызывается только если этот токен равен текущему. Запоздавший 401 от прежнего токена игнорируется. -6. **Вход и 4xx (T3.2).** `LoginScreen`: `ApiError` → «хаб отверг запрос: »; `HubDownError` — прежний +6. **Вход и 4xx (T3.2).** `LoginScreen`: `ApiError` → «Хаб отверг проверку токена: »; `HubDownError` — прежний текст «хаб не отвечает». 7. **Фильтр вне вариантов (T13.1).** Если значение фильтра из адреса не найдено в `opts[name]`, оно добавляется первым вариантом с подписью «<значение> (нет на доске)» — как текущее значение «вида работы» в заметке. @@ -58,7 +58,7 @@ jira-hub#29 (46 пунктов, маркеры сохраняются из PR #2 автоматической высотой. В `scenarios.json` добавляется сценарий `saved-short` с сохранённой раскладкой четырёх карточек разной высоты (120, 136, 200, 320), эталоны перегенерируются `npm run golden` из `links.html@3931a88`; прежние сценарии и фикстура `hub-basic.board.json` не меняются. -13. **e2e (T15.4, T15.6).** Порт тестового хаба — одна константа в `web/e2e/hub.ts` и `playwright.config.ts` берёт её же; +13. **e2e (T15.4, T15.6).** Порт тестового хаба — одна константа (`HUB_PORT`) в `web/playwright.config.ts`, `web/e2e/hub.ts` импортирует её; `scripts/test-hub.sh` перед стартом проверяет, что порт свободен, и выходит с понятной ошибкой. Готовность — e2e проходит 10 раз подряд. 14. **Состав плана.** 8 задач: хаб; клиент и вход; источник доски; холст и страница доски; задачи и карточка; модель, @@ -157,4 +157,4 @@ jira-hub#29 (46 пунктов, маркеры сохраняются из PR #2 добавлена защита `isConnected` (запоздавшее scroll после ухода с доски не затирает запомненное), e2e M-3 переписан на клик по видимой карточке без ожидания кадров. Обоснование: воспроизвести потерю прокрутки у пользователя, кликающего по видимой карточке, не удалось ни в e2e, ни вручную. Цена ошибки: если дефект есть у реальных пользователей (например, - в браузере с иной раскладкой событий scroll), прокрутка после возврата будет 0; чинится сохранением в cleanup размонтирования. + в браузере с иной раскладкой событий scroll), прокрутка после возврата будет 0; чинится сохранением в cleanup `useLayoutEffect` (до снятия DOM). diff --git a/openspec/changes/web-app-followups/proposal.md b/openspec/changes/web-app-followups/proposal.md index 390a102..8611201 100644 --- a/openspec/changes/web-app-followups/proposal.md +++ b/openspec/changes/web-app-followups/proposal.md @@ -10,7 +10,7 @@ dev-зависимости, неаккуратный React-код, слабые - M-3: при возврате с карточки задачи на доску восстанавливается прокрутка; держать в памяти снимок из файла и строку состояния не нужно — данные идут из хаба, файл — запасной путь (эта часть M-3 — rejected); - шрифты — те же IBM Plex Sans и Mono, но из сборки, без обращения в интернет; -- M-5 (сборка в wheel) — rejected: хаб запускается из checkout (`uv run --project`), wheel не используется. +- M-5 (сборка в wheel) — rejected: хаб запускается из checkout (`uv run --project`), wheel не используется (показано владельцу в сводке понимания до утверждения разделов, возражений не было). ## What Changes diff --git a/openspec/changes/web-app-followups/specs/web-app/spec.md b/openspec/changes/web-app-followups/specs/web-app/spec.md index c3ea5a9..aa6a018 100644 --- a/openspec/changes/web-app-followups/specs/web-app/spec.md +++ b/openspec/changes/web-app-followups/specs/web-app/spec.md @@ -73,7 +73,7 @@ SHALL проверять агента (`GET /agent`; офлайн — преду `POST /refresh {full: true, author: "viewer"}`, опрашивать `GET /batches/{id}` каждые 3 с одним опросом до терминального статуса пакета и затем перечитывать `GET /board`. Кнопка «Обновить из Jira» SHALL быть недоступна, пока запрос `/refresh` в пути или пакет выполняется. Незавершённый пакет `refresh` с автором `viewer` SHALL -подхватываться при каждой успешной загрузке из хаба; пакеты `viewer` с другими командами не подхватываются. Опрос пакета SHALL прекращаться при ответе 404 и после 5 ошибок +подхватываться при каждой успешной загрузке из хаба, кроме перечитывания снимка после завершения собственного пакета; пакеты `viewer` с другими командами не подхватываются. Опрос пакета SHALL прекращаться при ответе 404 и после 5 ошибок «хаб не отвечает» подряд, с сообщением и снова доступной кнопкой. Шапка доски SHALL показывать источник данных (данные хаба на время последнего полного прогона; «из файла») и одно текущее событие (загрузка; пакет выполняется / выполнен / ошибка с текстом; хаб не отвечает — прежние данные, а при снимке из файла — «на доске снимок из файла»; агент офлайн — пакет поставлен; ошибка diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index ecea01a..ba29d91 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -200,7 +200,7 @@ def h_app(hub, conn, m, q, body): try: f = (hub.web_dir / rest).resolve() except (OSError, ValueError): - raise ApiError(404, "not_found", "/app/" + rest) + raise ApiError(404, "not_found", "/app/" + rest) from None if not f.is_relative_to(assets) or not f.is_file(): raise ApiError(404, "not_found", "/app/" + rest) return 200, Raw(ASSET_TYPES.get(f.suffix, "application/octet-stream"), f.read_bytes(), ASSET_CACHE) diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 1d35840..61997b0 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -14,6 +14,8 @@ export default defineConfig({ command: "npm run build && bash scripts/test-hub.sh", url: `${HUB_URL}/app/`, reuseExistingServer: false, + stdout: "pipe", + stderr: "pipe", timeout: 120_000, 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 9173969..bdf3736 100644 --- a/web/scripts/test-hub.sh +++ b/web/scripts/test-hub.sh @@ -5,7 +5,7 @@ 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}" +PORT="${PORT:-8791}" # держать равным HUB_PORT в playwright.config.ts case "$JIRA_HUB_HOME" in "$HOME/.local/share/jira-hub"*) echo "отказ: это каталог живого хаба" >&2; exit 2;; esac # чистим только каталог внутри web/.e2e-hub (T15.5): чужой путь из переменной окружения не удаляем case "$JIRA_HUB_HOME" in diff --git a/web/src/api/queries.test.ts b/web/src/api/queries.test.ts new file mode 100644 index 0000000..ed8eabc --- /dev/null +++ b/web/src/api/queries.test.ts @@ -0,0 +1,37 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setToken } from "../auth/token.ts"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { qk, useIssues, useSaveNote } from "./queries.ts"; +import type { IssueDetail } from "./types.ts"; + +const wrap = (client: QueryClient) => ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children); +const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ key: `A-${i}` })); + +describe("запросы", () => { + beforeEach(() => setToken("tok")); + + it("getNextPageParam: 50 → offset 50, 17 → undefined", async () => { + const seen: (string | null)[] = []; + mockFetch((_m, url) => { seen.push(url.searchParams.get("offset")); return json(200, { issues: rows(seen.length === 1 ? 50 : 17) }); }); + const client = new QueryClient(); + const { result } = renderHook(() => useIssues({}), { wrapper: wrap(client) }); + await waitFor(() => expect(result.current.hasNextPage).toBe(true)); + await result.current.fetchNextPage(); + await waitFor(() => expect(result.current.hasNextPage).toBe(false)); + expect(seen).toEqual(["0", "50"]); + }); + + it("useSaveNote(A-1): ответ пишется в карточку A-1", async () => { + const note = { issue_key: "A-1", comment: "x", updated_by: "web" }; + mockFetch(() => json(200, note)); + const client = new QueryClient(); + client.setQueryData(qk.issue("A-1"), { key: "A-1", note: null } as unknown as IssueDetail); + const { result } = renderHook(() => useSaveNote("A-1"), { wrapper: wrap(client) }); + await result.current.mutateAsync({} as never); + expect(client.getQueryData(qk.issue("A-1"))?.note).toEqual(note); + }); +}); diff --git a/web/src/api/queryClient.ts b/web/src/api/queryClient.ts index 14fe4b3..037358e 100644 --- a/web/src/api/queryClient.ts +++ b/web/src/api/queryClient.ts @@ -1,7 +1,7 @@ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { AuthError, dropTokenOn } from "./client.ts"; -// 401 в любом запросе или действии — экран входа с пояснением «токен не подошёл» +// 401 на запрос с текущим токеном — экран входа (запоздавший 401 прежнего токена игнорируется, R-3) export function makeQueryClient(): QueryClient { const onError = (e: unknown) => { if (e instanceof AuthError) dropTokenOn(e); }; return new QueryClient({ diff --git a/web/src/auth/LoginScreen.test.tsx b/web/src/auth/LoginScreen.test.tsx index 31757c8..a4ffa71 100644 --- a/web/src/auth/LoginScreen.test.tsx +++ b/web/src/auth/LoginScreen.test.tsx @@ -9,6 +9,8 @@ import { clearToken, getAuth, setToken } from "./token.ts"; const app =

страница приложения

; +function Page() { useBoard(); return

страница приложения

; } + describe("вход", () => { beforeEach(() => clearToken()); @@ -60,7 +62,6 @@ describe("вход", () => { it("401 в запросе страницы — экран входа «токен не подошёл», страница скрыта (T3.6)", async () => { setToken("stale"); mockFetch(() => json(401, { error: "unauthorized" })); - function Page() { useBoard(); return

страница приложения

; } renderWithProviders(); expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument(); expect(screen.queryByText("страница приложения")).not.toBeInTheDocument(); @@ -72,7 +73,6 @@ describe("вход", () => { let release!: (r: Response) => void; const slow = new Promise((r) => { release = r; }); mockFetch((_m, url) => (url.pathname === "/board" ? slow : json(200, {}))); - function Page() { useBoard(); return

страница приложения

; } const { client } = renderWithProviders(); act(() => setToken("new-token")); release(json(401, { error: "unauthorized" })); diff --git a/web/src/board/analysis/analysis.test.ts b/web/src/board/analysis/analysis.test.ts index 8e2b6d6..bf0aa8d 100644 --- a/web/src/board/analysis/analysis.test.ts +++ b/web/src/board/analysis/analysis.test.ts @@ -51,4 +51,15 @@ describe("выборка = links.html@3931a88", () => { check([], golden.facets.rememberedAll); check(["AWPCA_Спринт 3", "нет такого"], golden.facets.rememberedSome); }); + + it("initialWho: из запомненного остаются только исполнители, которые есть на доске", () => { + const real = "Иванов Иван Иванович"; + expect(canon(initialWho(model, noFilters(), [real, "нет такого"]))).toEqual([real]); + }); + + it("initialWho: все задачи без исполнителя — выбор пуст, даже если запомнен пустой ключ", () => { + const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot; + const bare = fromHub({ ...snap, issues: snap.issues.map((i) => ({ ...i, assignee: null })) }, dicts); + expect(initialWho(bare, noFilters(), [""]).size).toBe(0); + }); }); diff --git a/web/src/board/analysis/facets.ts b/web/src/board/analysis/facets.ts index c432857..2b526aa 100644 --- a/web/src/board/analysis/facets.ts +++ b/web/src/board/analysis/facets.ts @@ -35,11 +35,13 @@ export function sprintBox(model: BoardModel): Box { if (!model.hasSprint) return { kind: "text", text: "колонки «Спринт» нет — показаны все задачи" }; return { kind: "multi" }; } +// список исполнителей имеет смысл, если есть хотя бы один назначенный (единственный пустой ключ — «все без исполнителя») +const isMultiWho = (names: string[]) => names.length > 0 && !(names.length === 1 && names[0] === ""); // то же для исполнителей export function whoBox(model: BoardModel, f: Filters): Box { if (!model.nodes.length) return { kind: "none" }; const names = Object.keys(whoItems(model, f).total); - if (!names.length || (names.length === 1 && names[0] === "")) return { kind: "text", text: names.length ? "все задачи без исполнителя" : "колонки «Исполнитель» нет" }; + if (!isMultiWho(names)) return { kind: "text", text: names.length ? "все задачи без исполнителя" : "колонки «Исполнитель» нет" }; return { kind: "multi" }; } @@ -59,6 +61,6 @@ export function initialWho(model: BoardModel, f: Filters, remembered: unknown): if (!model.nodes.length) return new Set(); const { total } = whoItems(model, f); const names = Object.keys(total); - if (!names.length || (names.length === 1 && names[0] === "")) return new Set(); + if (!isMultiWho(names)) return new Set(); return new Set(strList(remembered).filter((w) => total[w] !== undefined)); } diff --git a/web/src/board/components/BoardCanvas.test.tsx b/web/src/board/components/BoardCanvas.test.tsx index 3ee4efd..5c0b041 100644 --- a/web/src/board/components/BoardCanvas.test.tsx +++ b/web/src/board/components/BoardCanvas.test.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { BoardSnapshot } from "../../api/types.ts"; import { renderWithProviders } from "../../test/render.tsx"; import { computeVisible } from "../analysis/visible.ts"; @@ -129,6 +129,8 @@ describe("BoardCanvas", () => { expect(p.onMoveLane).toHaveBeenCalledWith("Гудвилл", p.vis.lanes.length - 1); }); + afterEach(() => { Object.defineProperty(window, "scrollY", { configurable: true, value: 0 }); }); + it("M-3: прокрутка запоминается по событию scroll и восстанавливается при новом монтировании", () => { const to = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); const first = renderWithProviders(); @@ -138,8 +140,6 @@ describe("BoardCanvas", () => { first.unmount(); renderWithProviders(); expect(to).toHaveBeenCalledWith(0, 640); - Object.defineProperty(window, "scrollY", { configurable: true, value: 0 }); - vi.restoreAllMocks(); }); it("M-3: запоздавшее scroll после ухода (прокрутка зажата в 0) не затирает запомненное", () => { @@ -157,6 +157,5 @@ describe("BoardCanvas", () => { Object.defineProperty(window, "scrollY", { configurable: true, value: 0 }); handlers.forEach((h) => h(new Event("scroll"))); // событие, дошедшее до слушателя уже после снятия доски expect(readBoardScroll()).toMatchObject({ y: 640 }); - vi.restoreAllMocks(); }); }); diff --git a/web/src/board/components/MultiSelect.tsx b/web/src/board/components/MultiSelect.tsx index fdb587c..67d28ad 100644 --- a/web/src/board/components/MultiSelect.tsx +++ b/web/src/board/components/MultiSelect.tsx @@ -13,7 +13,7 @@ export function MultiSelect({ items, selected, allLabel, onChange }: { const flip = (v: string, on: boolean) => { const n = new Set(selected); if (on) n.add(v); else n.delete(v); onChange(n); }; return ( setQ("")} button={({ open, toggle }) => ( - )}> diff --git a/web/src/board/useBoardSource.test.tsx b/web/src/board/useBoardSource.test.tsx index 22ece17..3d102d8 100644 --- a/web/src/board/useBoardSource.test.tsx +++ b/web/src/board/useBoardSource.test.tsx @@ -192,6 +192,7 @@ describe("useBoardSource", () => { expect(result.current.source).toBe("hub"); expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" }); }); + it("T10.3: «Перечитать» и CSV во время пакета не стирают строку пакета", async () => { hub({ "GET /batches/:id": () => batch("running") }); const { result } = hook(); @@ -213,6 +214,16 @@ describe("useBoardSource", () => { expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true }); }); + it("«Обновить из Jira» при показанном CSV: хаб недоступен — «на доске снимок из файла»", async () => { + hub({ "GET /agent": () => { throw new TypeError("Failed to fetch"); } }); + const { result } = hook(); + await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); + act(() => { result.current.loadFile("jira-export.csv", CSV); }); + expect(result.current.source).toBe("file"); + await act(() => result.current.refreshFromJira()); + expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true }); + }); + it("T10.4: пакет завершился при показанном CSV, хаб не ответил — «на доске снимок из файла»", async () => { let polls = 0, down = false; hub({ @@ -252,7 +263,8 @@ describe("useBoardSource", () => { expect(result.current.refreshing).toBe(false); expect(h.count("GET /batches/7")).toBe(1); // только проверка типа, опроса нет }); - it("T2.1: 401 на запуске пакета с текущим токеном — токен сброшен", async () => { + + it("401 на запуске пакета с текущим токеном — токен сброшен", async () => { hub({ "GET /agent": () => json(401, { error: "unauthorized" }) }); const { result } = hook(); await waitFor(() => expect(result.current.model.nodes.length).toBe(10)); @@ -260,7 +272,7 @@ describe("useBoardSource", () => { expect(getToken()).toBe(""); }); - it("T2.1: запоздавший 401 прежнему токену не сбрасывает новый токен", async () => { + it("запоздавший 401 прежнему токену не сбрасывает новый токен", async () => { const slow = deferred(); hub({ "GET /agent": () => slow.promise }); const { result } = hook(); diff --git a/web/src/board/useBoardSource.ts b/web/src/board/useBoardSource.ts index e7549a5..c0654cf 100644 --- a/web/src/board/useBoardSource.ts +++ b/web/src/board/useBoardSource.ts @@ -154,7 +154,8 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll setActive({ id: b.id, agentOffline: !agent.online }); setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds, agentOffline: !agent.online, retrying: false }); } catch (e) { - report(e, !!board.data); + const fromFile = sourceRef.current.kind === "file"; + report(e, !!board.data || fromFile, fromFile); } finally { startingRef.current = false; setStarting(false); diff --git a/web/src/issues/IssuesPage.test.tsx b/web/src/issues/IssuesPage.test.tsx index f29233f..76f404d 100644 --- a/web/src/issues/IssuesPage.test.tsx +++ b/web/src/issues/IssuesPage.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; +import { qk } from "../api/queries.ts"; import { setToken } from "../auth/token.ts"; import settings from "../board/__fixtures__/settings.json"; import { json, mockFetch } from "../test/fetchMock.ts"; @@ -118,18 +119,20 @@ describe("список задач", () => { expect(sel).toHaveValue("AWPCA_Спринт 0"); }); - it("T5.1: значение есть на доске, снимок грузится — подпись без пометки и после загрузки", async () => { + it("значение есть на доске, снимок грузится — подпись без пометки и после загрузки", async () => { const board = deferred(); mockFetch((_m, url) => { if (url.pathname === "/settings") return json(200, settings); if (url.pathname === "/board") return board.promise; return json(200, { issues: [row(1)] }); }); - renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" }); + const { client } = renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); const sel = await screen.findByLabelText("Спринт"); expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument(); + expect(client.getQueryData(qk.board)).toBeUndefined(); board.resolve(json(200, SNAP)); + await waitFor(() => expect(client.getQueryData(qk.board)).toBeDefined()); // снимок реально применён await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument()); expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull(); expect(sel).toHaveValue("AWPCA_Спринт 2"); diff --git a/web/vite.config.ts b/web/vite.config.ts index 0e500d1..e87bd03 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,7 +2,7 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; // сервер разработки: приложение под /app/, маршруты API — прокси на хаб (по умолчанию тестовый :8791, R-5) -const hub = process.env.JIRA_HUB_URL ?? "http://127.0.0.1:8791"; +const hub = process.env.JIRA_HUB_URL ?? "http://127.0.0.1:8791"; // порт держать равным HUB_PORT в playwright.config.ts const API = ["/board", "/issues", "/settings", "/agent", "/refresh", "/batches", "/commands", "/sync-runs"]; export default defineConfig({