refactor(web): обновление из Jira — состояние оболочки, доска хранит только источник (#65)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d6ddca0751
commit
0a1fd832ff
@ -3,6 +3,7 @@ import { useState } from "react";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { makeQueryClient } from "./api/queryClient.ts";
|
||||
import { AuthGate } from "./auth/AuthGate.tsx";
|
||||
import { RefreshProvider } from "./shell/RefreshProvider.tsx";
|
||||
import { routes } from "./routes.tsx";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/base.css";
|
||||
@ -15,7 +16,7 @@ export function App({ router }: { router?: Router }) {
|
||||
const [r] = useState(() => router ?? createBrowserRouter(routes, { basename: "/app" }));
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<AuthGate><RouterProvider router={r} /></AuthGate>
|
||||
<AuthGate><RefreshProvider><RouterProvider router={r} /></RefreshProvider></AuthGate>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -67,9 +67,9 @@ export function BoardPage({ project }: { project: string }) {
|
||||
return (
|
||||
<div data-testid="page-board">
|
||||
<div className="sticky" ref={headRef}>
|
||||
<Toolbar label={src.label} title={src.title} status={src.status} refreshing={src.refreshing} byDate={prefs.filters.byDate}
|
||||
<Toolbar label={src.label} title={src.title} status={src.status} byDate={prefs.filters.byDate}
|
||||
snap={prefs.snap} model={src.model} vis={vis} problemCount={problemCount(vis.problems)} problemsOpen={prefs.problems}
|
||||
tableOpen={prefs.table} onReload={src.reload} onRefresh={() => void src.refreshFromJira()} onFile={onFile}
|
||||
tableOpen={prefs.table} onReload={src.reload} onFile={onFile}
|
||||
onProblems={() => prefs.toggle("problems")} onExport={onExport} onTable={() => prefs.toggle("table")}
|
||||
onSnap={() => prefs.toggle("snap")} onReset={prefs.resetLayout} />
|
||||
<FilterBar model={src.model} prefs={prefs} />
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useJiraRefresh } from "../../shell/refresh.ts";
|
||||
import { refreshText } from "../../shell/refreshStatus.ts";
|
||||
import { applyTheme, readTheme, saveTheme, THEME_NAMES, THEMES, type Theme } from "../../shell/theme.ts";
|
||||
import { statusText, type BoardStatus } from "../status.ts";
|
||||
import type { BoardModel, Visible } from "../types.ts";
|
||||
@ -6,9 +8,9 @@ import { NOT_IN_HUB } from "../constants.ts";
|
||||
import { Dropdown } from "../../ui/index.ts";
|
||||
|
||||
interface ToolbarProps {
|
||||
label: string; title: string; status: BoardStatus | null; refreshing: boolean; byDate: boolean; snap: boolean;
|
||||
label: string; title: string; status: BoardStatus | null; byDate: boolean; snap: boolean;
|
||||
model: BoardModel; vis: Visible; problemCount: number; problemsOpen: boolean; tableOpen: boolean;
|
||||
onReload(): void; onRefresh(): void; onFile(name: string, text: string): void; onProblems(): void;
|
||||
onReload(): void; onFile(name: string, text: string): void; onProblems(): void;
|
||||
onExport(): void; onTable(): void; onSnap(): void; onReset(): void;
|
||||
}
|
||||
|
||||
@ -16,7 +18,9 @@ export function Toolbar(p: ToolbarProps) {
|
||||
const [theme, setTheme] = useState<Theme>(readTheme);
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
useEffect(() => { if (!confirm) return; const t = setTimeout(() => setConfirm(false), 8000); return () => clearTimeout(t); }, [confirm]);
|
||||
const st = p.status ? statusText(p.status) : null;
|
||||
const refresh = useJiraRefresh(); // кнопка и ход пакета временно здесь; в верхней панели — с Task 5
|
||||
const rt = refresh.event ? refreshText(refresh.event) : null;
|
||||
const st = p.status ? statusText(p.status) : rt ? { text: rt.text, warn: rt.tone === "error" || rt.tone === "warning" } : null;
|
||||
const m = p.model, v = p.vis, r = v.removed;
|
||||
const coreShown = v.nodes.filter((n) => !n.dim).length, ctxShown = v.nodes.length - coreShown;
|
||||
const nextTheme = () => { const t = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length]; setTheme(t); saveTheme(t); applyTheme(t); };
|
||||
@ -26,7 +30,7 @@ export function Toolbar(p: ToolbarProps) {
|
||||
<span className="src">
|
||||
<span className="file-name" title={p.title}>{p.label}</span>
|
||||
<button type="button" className="link" title="Загрузить снимок зеркала хаба заново, без обращения к Jira" onClick={p.onReload}>Перечитать</button>
|
||||
<button type="button" className="link" disabled={p.refreshing} onClick={p.onRefresh}
|
||||
<button type="button" className="link" disabled={refresh.busy} onClick={() => void refresh.start()}
|
||||
title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">Обновить из Jira</button>
|
||||
<label className="link" title="CSV из фильтра Jira или JSON, выгруженный этой доской. JSON при работе с хабом переносит только расстановку карточек, дорожки и фильтры; CSV показывает снимок файла до «Перечитать»">
|
||||
файл…
|
||||
|
||||
@ -2,16 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import { statusText } from "./status.ts";
|
||||
|
||||
describe("statusText", () => {
|
||||
it("пакет: офлайн, очередь, остаток, повтор", () => {
|
||||
expect(statusText({ kind: "batch", id: 7, phase: "queued", remaining: 12, agentOffline: true, retrying: false }))
|
||||
.toEqual({ text: "агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #7 в очереди, осталось ~12 с…", warn: false });
|
||||
expect(statusText({ kind: "batch", id: 7, phase: "running", remaining: 0, agentOffline: false, retrying: true }))
|
||||
.toEqual({ text: "пакет #7 выполняется… (хаб не отвечает, повтор через 3 с)", warn: true });
|
||||
});
|
||||
it("исходы и ошибки", () => {
|
||||
expect(statusText({ kind: "batch-failed", id: 7, error: "429" })).toEqual({ text: "пакет #7 не выполнен: 429", warn: true });
|
||||
expect(statusText({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }).text)
|
||||
.toBe("пакет #7: пакета нет в хабе — опрос остановлен, «Обновить из Jira» снова доступна");
|
||||
it("состояния доски", () => {
|
||||
expect(statusText({ kind: "loading" })).toEqual({ text: "загрузка данных хаба…", warn: false });
|
||||
expect(statusText({ kind: "hub-down", hasData: true, fromFile: false }).text).toBe("хаб не отвечает — показаны прежние данные");
|
||||
expect(statusText({ kind: "hub-down", hasData: true, fromFile: true }).text).toBe("хаб не отвечает — на доске снимок из файла");
|
||||
expect(statusText({ kind: "file-error", fileName: "a.csv", reason: "в файле нет строк с задачами" }).text)
|
||||
|
||||
@ -1,13 +1,6 @@
|
||||
export const POLL_MS = 3000; // период опроса пакета, мс (как у links.html)
|
||||
export const MAX_POLL_ERRORS = 5; // столько ошибок «хаб не отвечает» подряд — и опрос пакета останавливается (M-4)
|
||||
|
||||
// события строки состояния доски; ход пакета обновления из Jira — в оболочке (shell/refreshStatus.ts)
|
||||
export type BoardStatus =
|
||||
| { kind: "loading" }
|
||||
| { kind: "batch"; id: number; phase: "queued" | "running"; remaining: number; agentOffline: boolean; retrying: boolean }
|
||||
| { kind: "batch-done"; id: number }
|
||||
| { kind: "batch-failed"; id: number; error: string }
|
||||
| { kind: "batch-cancelled"; id: number }
|
||||
| { kind: "batch-lost"; id: number; reason: string }
|
||||
| { kind: "hub-down"; hasData: boolean; fromFile: boolean }
|
||||
| { kind: "file-error"; fileName: string; reason: string }
|
||||
| { kind: "json-applied"; fileName: string }
|
||||
@ -17,16 +10,6 @@ export type BoardStatus =
|
||||
export function statusText(s: BoardStatus): { text: string; warn: boolean } {
|
||||
switch (s.kind) {
|
||||
case "loading": return { text: "загрузка данных хаба…", warn: false };
|
||||
case "batch": {
|
||||
const off = s.agentOffline ? "агент офлайн — пакет выполнится, когда он выйдет на связь; " : "";
|
||||
const left = s.remaining ? `, осталось ~${s.remaining} с` : "";
|
||||
const retry = s.retrying ? ` (хаб не отвечает, повтор через ${POLL_MS / 1000} с)` : "";
|
||||
return { text: `${off}пакет #${s.id} ${s.phase === "queued" ? "в очереди" : "выполняется"}${left}…${retry}`, warn: s.retrying };
|
||||
}
|
||||
case "batch-done": return { text: `пакет #${s.id} выполнен`, warn: false };
|
||||
case "batch-failed": return { text: `пакет #${s.id} не выполнен: ${s.error}`, warn: true };
|
||||
case "batch-cancelled": return { text: `пакет #${s.id} отменён`, warn: true };
|
||||
case "batch-lost": return { text: `пакет #${s.id}: ${s.reason} — опрос остановлен, «Обновить из Jira» снова доступна`, warn: true };
|
||||
case "hub-down": return {
|
||||
text: s.fromFile ? "хаб не отвечает — на доске снимок из файла"
|
||||
: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»",
|
||||
|
||||
@ -1,49 +1,23 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { qk } from "../api/queries.ts";
|
||||
import { makeQueryClient } from "../api/queryClient.ts";
|
||||
import { getToken, setToken } from "../auth/token.ts";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
import { json } from "../test/fetchMock.ts";
|
||||
import { batch, deferred, fixture as fx, hub, SNAP } from "../test/batchHub.ts";
|
||||
import { useJiraRefresh } from "../shell/refresh.ts";
|
||||
import { RefreshProvider } from "../shell/RefreshProvider.tsx";
|
||||
import { dictsFromSettings } from "./dicts.ts";
|
||||
import settings from "./__fixtures__/settings.json";
|
||||
import { useBoardSource } from "./useBoardSource.ts";
|
||||
|
||||
const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8");
|
||||
const SNAP = JSON.parse(fx("hub-basic.board.json"));
|
||||
const CSV = fx("jira-export.csv");
|
||||
const { dicts } = dictsFromSettings(settings);
|
||||
const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useBoardSource(dicts, "AWPCA", { pollMs: 10 }),
|
||||
{ wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> }) });
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
|
||||
// хаб в памяти: снимок, список пакетов, пакет по id; счётчики запросов
|
||||
function hub(over: Partial<Record<string, (url: URL, body: unknown) => Response | Promise<Response>>> = {}) {
|
||||
const calls: string[] = [];
|
||||
const inFlight: Record<string, number> = {}, maxInFlight: Record<string, number> = {};
|
||||
const fetch = mockFetch(async (method, url, body) => {
|
||||
const k = `${method} ${url.pathname}`;
|
||||
calls.push(k);
|
||||
inFlight[k] = (inFlight[k] ?? 0) + 1;
|
||||
maxInFlight[k] = Math.max(maxInFlight[k] ?? 0, inFlight[k]);
|
||||
try {
|
||||
const h = over[k] ?? over[`${method} ${url.pathname.replace(/\d+$/, ":id")}`];
|
||||
if (h) return await h(url, body);
|
||||
if (k === "GET /board") return json(200, SNAP);
|
||||
if (k === "GET /batches") return json(200, { batches: [] });
|
||||
if (k === "GET /agent") return json(200, { online: true, last_seen_at: null, warning: null });
|
||||
if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
|
||||
return json(404, { error: "not_found" });
|
||||
} finally { inFlight[k] -= 1; }
|
||||
});
|
||||
return { fetch, calls, count: (k: string) => calls.filter((c) => c === k).length, maxInFlight: (k: string) => maxInFlight[k] ?? 0 };
|
||||
}
|
||||
const batch = (status: string, cmd: Record<string, unknown> = { status: "done" }) =>
|
||||
json(200, { id: 7, title: "Обновление", author: "viewer", created_at: "", cancelled_at: null, status, counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, ...cmd }] });
|
||||
// доска под провайдером обновления из Jira (как в приложении); refresh — состояние оболочки
|
||||
const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => ({ ...useBoardSource(dicts, "AWPCA"), refresh: useJiraRefresh() }),
|
||||
{ wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}><RefreshProvider pollMs={10}>{children}</RefreshProvider></QueryClientProvider> }) });
|
||||
|
||||
describe("useBoardSource", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
@ -73,93 +47,17 @@ describe("useBoardSource", () => {
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
});
|
||||
|
||||
it("M-2: двойное нажатие — один пакет и один опрос за раз", async () => {
|
||||
let polls = 0;
|
||||
const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(async () => { void result.current.refreshFromJira(); await result.current.refreshFromJira(); });
|
||||
expect(h.count("POST /refresh")).toBe(1);
|
||||
expect(result.current.refreshing).toBe(true);
|
||||
await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 }));
|
||||
expect(result.current.refreshing).toBe(false);
|
||||
expect(h.maxInFlight("GET /batches/7")).toBe(1);
|
||||
expect(h.count("GET /board")).toBe(2); // по завершении снимок перечитан
|
||||
expect(h.count("GET /batches")).toBe(1); // T10.1: после своего пакета список пакетов не перечитывается
|
||||
});
|
||||
|
||||
it("M-3: незавершённый пакет viewer подхватывается и после «Перечитать»", async () => {
|
||||
let listed: unknown[] = [];
|
||||
const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(h.count("GET /batches")).toBe(1));
|
||||
expect(result.current.refreshing).toBe(false);
|
||||
listed = [{ id: 7, author: "viewer", status: "running" }, { id: 6, author: "chat", status: "running" }];
|
||||
act(() => result.current.reload());
|
||||
await waitFor(() => expect(result.current.refreshing).toBe(true));
|
||||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7, phase: "running" }));
|
||||
});
|
||||
|
||||
it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => {
|
||||
const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", id: 7 }));
|
||||
expect(result.current.refreshing).toBe(false);
|
||||
const n = h.count("GET /batches/7");
|
||||
await sleep(50);
|
||||
expect(h.count("GET /batches/7")).toBe(n);
|
||||
});
|
||||
|
||||
for (const status of [400, 422]) {
|
||||
it(`T10.2: ${status} на опросе — опрос остановлен, batch-lost с detail, refreshing сброшен`, async () => {
|
||||
const h = hub({ "GET /batches/:id": () => json(status, { error: "bad_request", detail: "плохой пакет" }) });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", id: 7, reason: expect.stringContaining("плохой пакет") }));
|
||||
expect(result.current.refreshing).toBe(false);
|
||||
const n = h.count("GET /batches/7");
|
||||
await sleep(50);
|
||||
expect(h.count("GET /batches/7")).toBe(n);
|
||||
});
|
||||
}
|
||||
|
||||
it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => {
|
||||
const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", reason: "хаб не отвечает" }));
|
||||
expect(h.count("GET /batches/7")).toBe(5);
|
||||
await sleep(50);
|
||||
expect(h.count("GET /batches/7")).toBe(5);
|
||||
expect(result.current.refreshing).toBe(false);
|
||||
});
|
||||
|
||||
it("пакет с ошибкой команды — текст ошибки; агент офлайн — предупреждение в статусе", async () => {
|
||||
let polls = 0;
|
||||
hub({
|
||||
"GET /agent": () => json(200, { online: false, last_seen_at: null, warning: null }),
|
||||
"GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done", { status: "failed", error: "Jira 500" })),
|
||||
});
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", agentOffline: true }));
|
||||
await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" }));
|
||||
});
|
||||
|
||||
it("M-1: пакет, завершившийся при показанном файле, не заменяет файл", async () => {
|
||||
let polls = 0;
|
||||
hub({ "GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done")) });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
await act(() => result.current.refresh.start());
|
||||
act(() => { result.current.loadFile("jira-export.csv", CSV); });
|
||||
await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 }));
|
||||
await waitFor(() => expect(result.current.refresh.event).toEqual({ kind: "batch-done", id: 7 }));
|
||||
expect(result.current.source).toBe("file");
|
||||
expect(result.current.label).toBe("jira-export.csv (из файла)");
|
||||
});
|
||||
|
||||
it("хаб остановлен при «Перечитать» — прежние данные и hub-down", async () => {
|
||||
@ -193,16 +91,18 @@ describe("useBoardSource", () => {
|
||||
expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" });
|
||||
});
|
||||
|
||||
it("T10.3: «Перечитать» и CSV во время пакета не стирают строку пакета", async () => {
|
||||
it("T10.3: «Перечитать» и CSV во время пакета не трогают ход пакета; шапка доски — без события пакета", async () => {
|
||||
hub({ "GET /batches/:id": () => batch("running") });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7 }));
|
||||
await act(() => result.current.refresh.start());
|
||||
await waitFor(() => expect(result.current.refresh.event).toMatchObject({ kind: "batch", id: 7 }));
|
||||
act(() => result.current.reload());
|
||||
expect(result.current.status).toMatchObject({ kind: "batch", id: 7 });
|
||||
expect(result.current.status).toBeNull();
|
||||
expect(result.current.refresh.event).toMatchObject({ kind: "batch", id: 7 });
|
||||
act(() => { result.current.loadFile("jira-export.csv", CSV); });
|
||||
expect(result.current.status).toMatchObject({ kind: "batch", id: 7 });
|
||||
expect(result.current.status).toBeNull();
|
||||
expect(result.current.refresh.event).toMatchObject({ kind: "batch", id: 7 });
|
||||
});
|
||||
|
||||
it("T10.4: JSON-снимок без данных хаба — «на доске снимок из файла»", async () => {
|
||||
@ -214,16 +114,6 @@ 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({
|
||||
@ -232,56 +122,9 @@ describe("useBoardSource", () => {
|
||||
});
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
await act(() => result.current.refresh.start());
|
||||
act(() => { result.current.loadFile("jira-export.csv", CSV); });
|
||||
await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true, fromFile: true }));
|
||||
expect(result.current.source).toBe("file");
|
||||
});
|
||||
|
||||
it("уход со страницы и возврат во время пакета — пакет подхвачен снова, один опрос (Review Focus 2)", async () => {
|
||||
let listed: unknown[] = [];
|
||||
const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") });
|
||||
const first = hook();
|
||||
await waitFor(() => expect(first.result.current.model.nodes.length).toBe(10));
|
||||
await act(() => first.result.current.refreshFromJira());
|
||||
listed = [{ id: 7, author: "viewer", status: "running" }];
|
||||
first.unmount();
|
||||
const second = hook(first.client);
|
||||
await waitFor(() => expect(second.result.current.status).toMatchObject({ kind: "batch", id: 7 }));
|
||||
expect(second.result.current.refreshing).toBe(true);
|
||||
expect(h.maxInFlight("GET /batches/7")).toBe(1);
|
||||
});
|
||||
|
||||
it("T10.6: незавершённый пакет viewer без refresh не подхватывается", async () => {
|
||||
const h = hub({
|
||||
"GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "running" }] }),
|
||||
"GET /batches/:id": () => batch("running", { type: "ensure_fields" }),
|
||||
});
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(h.count("GET /batches/7")).toBe(1));
|
||||
await sleep(50);
|
||||
expect(result.current.refreshing).toBe(false);
|
||||
expect(h.count("GET /batches/7")).toBe(1); // только проверка типа, опроса нет
|
||||
});
|
||||
|
||||
it("401 на запуске пакета с текущим токеном — токен сброшен", async () => {
|
||||
hub({ "GET /agent": () => json(401, { error: "unauthorized" }) });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
await act(() => result.current.refreshFromJira());
|
||||
expect(getToken()).toBe("");
|
||||
});
|
||||
|
||||
it("запоздавший 401 прежнему токену не сбрасывает новый токен", async () => {
|
||||
const slow = deferred<Response>();
|
||||
hub({ "GET /agent": () => slow.promise });
|
||||
const { result } = hook();
|
||||
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
|
||||
let run!: Promise<void>;
|
||||
act(() => { run = result.current.refreshFromJira(); });
|
||||
setToken("new-tok");
|
||||
slow.resolve(json(401, { error: "unauthorized" }));
|
||||
await act(() => run);
|
||||
expect(getToken()).toBe("new-tok");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,20 +1,17 @@
|
||||
// источник данных доски: снимок хаба (GET /board) или файл (CSV/JSON); обновление из Jira — пакет refresh от имени
|
||||
// viewer и опрос его хода. Устройство закрывает недочёты jira-hub#20: явный источник (M-1), одна кнопка и один опрос
|
||||
// (M-2), подхват незавершённого пакета после любой загрузки (M-3), остановка опроса (M-4), одно событие состояния (M-5)
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
// источник данных доски: снимок хаба (GET /board) или файл (CSV/JSON). Обновление из Jira живёт в оболочке
|
||||
// (shell/RefreshProvider.tsx); здесь — явный источник (M-1) и одно событие состояния доски (M-5)
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api, ApiError, AuthError, dropTokenOn, HubDownError } from "../api/client.ts";
|
||||
import { AuthError, dropTokenOn, HubDownError } from "../api/client.ts";
|
||||
import { qk, useBoard } from "../api/queries.ts";
|
||||
import type { AgentView, Batch, CreatedBatch } from "../api/types.ts";
|
||||
import { fromCsv } from "./model/fromCsv.ts";
|
||||
import { fromHub } from "./model/fromHub.ts";
|
||||
import { fromJson } from "./model/fromJson.ts";
|
||||
import { emptyModel, fmtStamp } from "./model/util.ts";
|
||||
import { MAX_POLL_ERRORS, POLL_MS, type BoardStatus } from "./status.ts";
|
||||
import type { BoardStatus } from "./status.ts";
|
||||
import type { BoardModel, Dicts, JsonImport } from "./types.ts";
|
||||
|
||||
type Source = { kind: "hub" } | { kind: "file"; model: BoardModel };
|
||||
interface Active { id: number; agentOffline: boolean }
|
||||
|
||||
export interface BoardSource {
|
||||
model: BoardModel;
|
||||
@ -22,32 +19,21 @@ export interface BoardSource {
|
||||
label: string;
|
||||
title: string;
|
||||
status: BoardStatus | null;
|
||||
refreshing: boolean;
|
||||
reload(): void;
|
||||
refreshFromJira(): Promise<void>;
|
||||
loadFile(fileName: string, text: string): JsonImport | null;
|
||||
}
|
||||
|
||||
const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled");
|
||||
|
||||
export function useBoardSource(dicts: Dicts | null, project: string, { pollMs = POLL_MS }: { pollMs?: number } = {}): BoardSource {
|
||||
export function useBoardSource(dicts: Dicts | null, project: string): BoardSource {
|
||||
const qc = useQueryClient();
|
||||
const board = useBoard(project);
|
||||
const [source, setSource] = useState<Source>({ kind: "hub" });
|
||||
const [event, setEvent] = useState<BoardStatus | null>(null);
|
||||
const [active, setActive] = useState<Active | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const startingRef = useRef(false);
|
||||
const failures = useRef(0);
|
||||
|
||||
const hubModel = useMemo(() => (board.data && dicts ? fromHub(board.data, dicts) : null), [board.data, dicts]);
|
||||
|
||||
// свежие значения для эффектов, которые не должны перезапускаться от их смены
|
||||
const sourceRef = useRef(source);
|
||||
useEffect(() => { sourceRef.current = source; }, [source]);
|
||||
const activeRef = useRef(active);
|
||||
useEffect(() => { activeRef.current = active; }, [active]);
|
||||
const skipPickup = useRef(false); // T10.1: снимок, перечитанный после своего пакета, — пакет уже завершён
|
||||
|
||||
const report = useCallback((e: unknown, hasData: boolean, fromFile = false) => {
|
||||
if (e instanceof AuthError) dropTokenOn(e);
|
||||
@ -58,113 +44,12 @@ export function useBoardSource(dicts: Dicts | null, project: string, { pollMs =
|
||||
// хаб не ответил на снимок: прежние данные остаются (M-5 — отдельное событие, не затирается другими)
|
||||
useEffect(() => {
|
||||
if (!board.error || board.error instanceof AuthError) return;
|
||||
skipPickup.current = false;
|
||||
report(board.error, !!board.data, sourceRef.current.kind === "file");
|
||||
}, [board.error, board.errorUpdatedAt, board.data, report]);
|
||||
|
||||
// M-3 #20: после каждой успешной загрузки снимка — подхватить незавершённый пакет обновления от viewer
|
||||
useEffect(() => {
|
||||
if (!board.dataUpdatedAt) return;
|
||||
if (skipPickup.current) { skipPickup.current = false; return; }
|
||||
if (activeRef.current) return;
|
||||
let gone = false;
|
||||
(async () => {
|
||||
const r = await api.get<{ batches: Batch[] }>("/batches", { limit: 20 });
|
||||
const b = r.batches.find((x) => x.author === "viewer" && (x.status === "queued" || x.status === "running"));
|
||||
if (!b || gone) return;
|
||||
const full = await api.get<Batch>(`/batches/${b.id}`); // T10.6: в списке нет команд — тип смотрим в пакете (R-2)
|
||||
if (gone || full.commands?.[0]?.type !== "refresh") return;
|
||||
failures.current = 0;
|
||||
setActive((cur) => cur ?? { id: b.id, agentOffline: false });
|
||||
})().catch(() => { /* не критично: подхватим при следующей загрузке */ });
|
||||
return () => { gone = true; };
|
||||
}, [board.dataUpdatedAt]);
|
||||
|
||||
// M-2, M-4: один опрос пакета; остановка на завершении, любой 4xx (T10.2), 401 и после MAX_POLL_ERRORS ошибок подряд
|
||||
const batch = useQuery({
|
||||
queryKey: ["batch", active?.id ?? 0],
|
||||
enabled: active !== null,
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const b = await api.get<Batch>(`/batches/${active!.id}`);
|
||||
failures.current = 0;
|
||||
return b;
|
||||
} catch (e) {
|
||||
if (e instanceof HubDownError) failures.current += 1;
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
refetchInterval: (q) => {
|
||||
const e = q.state.error;
|
||||
if (terminal(q.state.data)) return false;
|
||||
if (e instanceof AuthError || e instanceof ApiError) return false; // любая 4xx не лечится повтором
|
||||
if (failures.current >= MAX_POLL_ERRORS) return false;
|
||||
return pollMs;
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
const finish = useCallback((id: number) => {
|
||||
failures.current = 0;
|
||||
setActive(null);
|
||||
qc.removeQueries({ queryKey: ["batch", id] });
|
||||
skipPickup.current = true;
|
||||
void qc.invalidateQueries({ queryKey: qk.board(project) }); // снимок обновится; показанный файл не заменяется (M-1)
|
||||
}, [qc, project]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const b = batch.data, e = batch.error;
|
||||
if (b && b.id === active.id && !batch.isError) {
|
||||
if (terminal(b)) {
|
||||
const cmd = b.commands?.[0];
|
||||
setEvent(cmd?.status === "failed" ? { kind: "batch-failed", id: b.id, error: cmd.error || "ошибка агента" }
|
||||
: b.status === "cancelled" || cmd?.status === "cancelled" || cmd?.status === "skipped" ? { kind: "batch-cancelled", id: b.id }
|
||||
: { kind: "batch-done", id: b.id });
|
||||
finish(b.id);
|
||||
return;
|
||||
}
|
||||
setEvent({ kind: "batch", id: b.id, phase: b.status === "queued" ? "queued" : "running",
|
||||
remaining: b.remaining_seconds, agentOffline: active.agentOffline, retrying: false });
|
||||
return;
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
setEvent({ kind: "batch-lost", id: active.id, reason: e.status === 404 ? "пакета нет в хабе" : e.detail ?? e.message });
|
||||
finish(active.id);
|
||||
} else if (e instanceof HubDownError) {
|
||||
if (failures.current >= MAX_POLL_ERRORS) {
|
||||
setEvent({ kind: "batch-lost", id: active.id, reason: "хаб не отвечает" });
|
||||
finish(active.id);
|
||||
} else {
|
||||
setEvent((cur) => (cur?.kind === "batch" ? { ...cur, retrying: true } : cur));
|
||||
}
|
||||
}
|
||||
}, [batch.data, batch.error, batch.isError, batch.errorUpdatedAt, batch.dataUpdatedAt, active, finish]);
|
||||
|
||||
const refreshFromJira = useCallback(async () => {
|
||||
if (startingRef.current || active) return;
|
||||
startingRef.current = true;
|
||||
setStarting(true);
|
||||
try {
|
||||
const agent = await api.get<AgentView>("/agent");
|
||||
const b = await api.post<CreatedBatch>("/refresh", { full: true, author: "viewer" });
|
||||
failures.current = 0;
|
||||
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) {
|
||||
const fromFile = sourceRef.current.kind === "file";
|
||||
report(e, !!board.data || fromFile, fromFile);
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
setStarting(false);
|
||||
}
|
||||
}, [active, report, board.data]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setSource({ kind: "hub" });
|
||||
setEvent((cur) => (cur?.kind === "batch" ? cur : null));
|
||||
setEvent(null);
|
||||
void qc.refetchQueries({ queryKey: qk.board(project) });
|
||||
}, [qc, project]);
|
||||
|
||||
@ -174,7 +59,7 @@ export function useBoardSource(dicts: Dicts | null, project: string, { pollMs =
|
||||
try {
|
||||
if (!isJson) {
|
||||
setSource({ kind: "file", model: fromCsv(text, fileName, dicts) });
|
||||
setEvent((cur) => (cur?.kind === "batch" ? cur : null));
|
||||
setEvent(null);
|
||||
return null;
|
||||
}
|
||||
const r = fromJson(text, fileName, dicts);
|
||||
@ -208,5 +93,5 @@ export function useBoardSource(dicts: Dicts | null, project: string, { pollMs =
|
||||
: label;
|
||||
const status: BoardStatus | null = event ?? (source.kind === "hub" && board.isPending ? { kind: "loading" } : null);
|
||||
|
||||
return { model, source: source.kind, label, title, status, refreshing: starting || active !== null, reload, refreshFromJira, loadFile };
|
||||
return { model, source: source.kind, label, title, status, reload, loadFile };
|
||||
}
|
||||
|
||||
169
web/src/shell/RefreshProvider.tsx
Normal file
169
web/src/shell/RefreshProvider.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
// владелец состояния «Обновить из Jira»: один опрос пакета на всё приложение. Правила — прежние (jira-hub#20):
|
||||
// одна кнопка и один опрос (M-2), подхват незавершённого пакета после любой загрузки снимка (M-3), остановка опроса
|
||||
// (M-4, T10.2), только пакеты с первой командой refresh (T10.6)
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { api, ApiError, AuthError, dropTokenOn, HubDownError } from "../api/client.ts";
|
||||
import { qk } from "../api/queries.ts";
|
||||
import type { AgentView, Batch, CreatedBatch } from "../api/types.ts";
|
||||
import { RefreshContext, type JiraRefresh } from "./refresh.ts";
|
||||
import { DONE_VISIBLE_MS, MAX_POLL_ERRORS, POLL_MS, type RefreshEvent } from "./refreshStatus.ts";
|
||||
|
||||
interface Active { id: number; agentOffline: boolean }
|
||||
|
||||
const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled");
|
||||
|
||||
export function RefreshProvider({ children, pollMs = POLL_MS }: { children: ReactNode; pollMs?: number }) {
|
||||
const qc = useQueryClient();
|
||||
const [event, setEvent] = useState<RefreshEvent | null>(null);
|
||||
const [active, setActive] = useState<Active | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const startingRef = useRef(false);
|
||||
const failures = useRef(0);
|
||||
const activeRef = useRef(active);
|
||||
useEffect(() => { activeRef.current = active; }, [active]);
|
||||
// T10.1: сколько ближайших загрузок снимка — перечитывание после своего пакета (пакет уже завершён, подхватывать нечего)
|
||||
const skipPickup = useRef(0);
|
||||
const picking = useRef(false);
|
||||
|
||||
const agent = useQuery({
|
||||
queryKey: ["agent"], refetchInterval: 60_000, refetchIntervalInBackground: true,
|
||||
queryFn: () => api.get<AgentView>("/agent"),
|
||||
});
|
||||
|
||||
const report = useCallback((e: unknown) => {
|
||||
if (e instanceof AuthError) dropTokenOn(e);
|
||||
else if (e instanceof HubDownError) setEvent({ kind: "hub-down" });
|
||||
else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) });
|
||||
}, []);
|
||||
|
||||
// M-3 #20: подхватить незавершённый пакет обновления от viewer
|
||||
const pickup = useCallback(() => {
|
||||
if (activeRef.current || startingRef.current || picking.current) return;
|
||||
picking.current = true;
|
||||
(async () => {
|
||||
const r = await api.get<{ batches: Batch[] }>("/batches", { limit: 20 });
|
||||
const b = r.batches.find((x) => x.author === "viewer" && (x.status === "queued" || x.status === "running"));
|
||||
if (!b) return;
|
||||
const full = await api.get<Batch>(`/batches/${b.id}`); // T10.6: в списке нет команд — тип смотрим в пакете (R-2)
|
||||
if (full.commands?.[0]?.type !== "refresh" || activeRef.current || startingRef.current) return;
|
||||
failures.current = 0;
|
||||
setActive((cur) => cur ?? { id: b.id, agentOffline: false });
|
||||
})().catch(() => { /* не критично: подхватим при следующей загрузке */ }).finally(() => { picking.current = false; });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
pickup();
|
||||
return qc.getQueryCache().subscribe((ev) => {
|
||||
if (ev.type !== "updated" || ev.action.type !== "success" || ev.query.queryKey[0] !== "board") return;
|
||||
if (skipPickup.current > 0) { skipPickup.current -= 1; return; }
|
||||
pickup();
|
||||
});
|
||||
}, [qc, pickup]);
|
||||
|
||||
// M-2, M-4: один опрос пакета; остановка на завершении, любой 4xx (T10.2), 401 и после MAX_POLL_ERRORS ошибок подряд
|
||||
const batch = useQuery({
|
||||
queryKey: ["batch", active?.id ?? 0],
|
||||
enabled: active !== null,
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const b = await api.get<Batch>(`/batches/${active!.id}`);
|
||||
failures.current = 0;
|
||||
return b;
|
||||
} catch (e) {
|
||||
if (e instanceof HubDownError) failures.current += 1;
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
refetchInterval: (q) => {
|
||||
const e = q.state.error;
|
||||
if (terminal(q.state.data)) return false;
|
||||
if (e instanceof AuthError || e instanceof ApiError) return false; // любая 4xx не лечится повтором
|
||||
if (failures.current >= MAX_POLL_ERRORS) return false;
|
||||
return pollMs;
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
const finish = useCallback((id: number) => {
|
||||
failures.current = 0;
|
||||
setActive(null);
|
||||
qc.removeQueries({ queryKey: ["batch", id] });
|
||||
// перечитывание активных снимков досок — после своего пакета, подхват не нужен; показанный файл не заменяется (M-1)
|
||||
skipPickup.current = qc.getQueryCache().findAll({ queryKey: qk.boardAll, type: "active" }).length;
|
||||
for (const key of [qk.boardAll, ["issues"], ["issue"], qk.projects]) void qc.invalidateQueries({ queryKey: key });
|
||||
}, [qc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const b = batch.data, e = batch.error;
|
||||
if (b && b.id === active.id && !batch.isError) {
|
||||
if (terminal(b)) {
|
||||
const cmd = b.commands?.[0];
|
||||
setEvent(cmd?.status === "failed" ? { kind: "batch-failed", id: b.id, error: cmd.error || "ошибка агента" }
|
||||
: b.status === "cancelled" || cmd?.status === "cancelled" || cmd?.status === "skipped" ? { kind: "batch-cancelled", id: b.id }
|
||||
: { kind: "batch-done", id: b.id });
|
||||
finish(b.id);
|
||||
return;
|
||||
}
|
||||
setEvent({ kind: "batch", id: b.id, phase: b.status === "queued" ? "queued" : "running",
|
||||
remaining: b.remaining_seconds, agentOffline: active.agentOffline, retrying: false });
|
||||
return;
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
setEvent({ kind: "batch-lost", id: active.id, reason: e.status === 404 ? "пакета нет в хабе" : e.detail ?? e.message });
|
||||
finish(active.id);
|
||||
} else if (e instanceof HubDownError) {
|
||||
if (failures.current >= MAX_POLL_ERRORS) {
|
||||
setEvent({ kind: "batch-lost", id: active.id, reason: "хаб не отвечает" });
|
||||
finish(active.id);
|
||||
} else {
|
||||
setEvent((cur) => (cur?.kind === "batch" ? { ...cur, retrying: true } : cur));
|
||||
}
|
||||
}
|
||||
}, [batch.data, batch.error, batch.isError, batch.errorUpdatedAt, batch.dataUpdatedAt, active, finish]);
|
||||
|
||||
// «пакет выполнен» гаснет сам
|
||||
useEffect(() => {
|
||||
if (event?.kind !== "batch-done") return;
|
||||
const t = setTimeout(() => setEvent((cur) => (cur === event ? null : cur)), DONE_VISIBLE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [event]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (startingRef.current || activeRef.current) return;
|
||||
startingRef.current = true;
|
||||
setStarting(true);
|
||||
try {
|
||||
const a = await api.get<AgentView>("/agent");
|
||||
const b = await api.post<CreatedBatch>("/refresh", { full: true, author: "viewer" });
|
||||
failures.current = 0;
|
||||
const next = { id: b.id, agentOffline: !a.online };
|
||||
activeRef.current = next;
|
||||
setActive(next);
|
||||
setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds, agentOffline: !a.online, retrying: false });
|
||||
} catch (e) {
|
||||
report(e);
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
setStarting(false);
|
||||
}
|
||||
}, [report]);
|
||||
|
||||
const track = useCallback((id: number) => {
|
||||
if (activeRef.current) return;
|
||||
failures.current = 0;
|
||||
const next = { id, agentOffline: false };
|
||||
activeRef.current = next;
|
||||
setActive(next);
|
||||
setEvent({ kind: "batch", id, phase: "queued", remaining: 0, agentOffline: false, retrying: false });
|
||||
}, []);
|
||||
|
||||
const value = useMemo<JiraRefresh>(() => ({
|
||||
event, busy: starting || active !== null, agent: agent.data ?? null, start, track,
|
||||
}), [event, starting, active, agent.data, start, track]);
|
||||
|
||||
return <RefreshContext.Provider value={value}>{children}</RefreshContext.Provider>;
|
||||
}
|
||||
222
web/src/shell/refresh.test.tsx
Normal file
222
web/src/shell/refresh.test.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, render, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useBoard, useIssues } from "../api/queries.ts";
|
||||
import { makeQueryClient } from "../api/queryClient.ts";
|
||||
import { getToken, setToken } from "../auth/token.ts";
|
||||
import { json } from "../test/fetchMock.ts";
|
||||
import { batch, deferred, hub, sleep } from "../test/batchHub.ts";
|
||||
import { useJiraRefresh, type JiraRefresh } from "./refresh.ts";
|
||||
import { RefreshProvider } from "./RefreshProvider.tsx";
|
||||
|
||||
const wrap = (client = makeQueryClient()) => ({ children }: { children: ReactNode }) =>
|
||||
<QueryClientProvider client={client}><RefreshProvider pollMs={10}>{children}</RefreshProvider></QueryClientProvider>;
|
||||
const refreshHook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useJiraRefresh(), { wrapper: wrap(client) }) });
|
||||
|
||||
describe("RefreshProvider", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it("вне провайдера — понятная ошибка", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => renderHook(() => useJiraRefresh())).toThrow(/RefreshProvider/);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("M-2: двойное нажатие — один пакет и один опрос за раз; по завершении board перечитан, список пакетов — нет", async () => {
|
||||
let polls = 0;
|
||||
const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) });
|
||||
const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() });
|
||||
await waitFor(() => expect(result.current.b.data).toBeDefined());
|
||||
await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1));
|
||||
const listed = h.count("GET /batches");
|
||||
await act(async () => { void result.current.r.start(); await result.current.r.start(); });
|
||||
expect(h.count("POST /refresh")).toBe(1);
|
||||
expect(result.current.r.busy).toBe(true);
|
||||
await waitFor(() => expect(result.current.r.event).toEqual({ kind: "batch-done", id: 7 }));
|
||||
expect(result.current.r.busy).toBe(false);
|
||||
expect(h.maxInFlight("GET /batches/7")).toBe(1);
|
||||
await waitFor(() => expect(h.count("GET /board")).toBe(2)); // по завершении снимок перечитан
|
||||
await sleep(30);
|
||||
expect(h.count("GET /batches")).toBe(listed); // T10.1: после своего пакета список пакетов не перечитывается
|
||||
});
|
||||
|
||||
it("M-3: незавершённый пакет viewer подхватывается после загрузки снимка (в том числе повторной)", async () => {
|
||||
let listed: unknown[] = [];
|
||||
const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") });
|
||||
const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA") }), { wrapper: wrap() });
|
||||
await waitFor(() => expect(h.count("GET /batches")).toBeGreaterThanOrEqual(1));
|
||||
expect(result.current.r.busy).toBe(false);
|
||||
listed = [{ id: 7, author: "viewer", status: "running" }, { id: 6, author: "chat", status: "running" }];
|
||||
await act(async () => { await result.current.b.refetch(); });
|
||||
await waitFor(() => expect(result.current.r.busy).toBe(true));
|
||||
await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch", id: 7, phase: "running" }));
|
||||
});
|
||||
|
||||
it("M-3: пакет подхватывается при монтировании провайдера", async () => {
|
||||
hub({ "GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "queued" }] }), "GET /batches/:id": () => batch("running") });
|
||||
const { result } = refreshHook();
|
||||
await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch", id: 7 }));
|
||||
expect(result.current.busy).toBe(true);
|
||||
});
|
||||
|
||||
it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => {
|
||||
const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) });
|
||||
const { result } = refreshHook();
|
||||
await act(() => result.current.start());
|
||||
await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }));
|
||||
expect(result.current.busy).toBe(false);
|
||||
const n = h.count("GET /batches/7");
|
||||
await sleep(50);
|
||||
expect(h.count("GET /batches/7")).toBe(n);
|
||||
});
|
||||
|
||||
for (const status of [400, 422]) {
|
||||
it(`T10.2: ${status} на опросе — опрос остановлен, batch-lost с detail, busy сброшен`, async () => {
|
||||
const h = hub({ "GET /batches/:id": () => json(status, { error: "bad_request", detail: "плохой пакет" }) });
|
||||
const { result } = refreshHook();
|
||||
await act(() => result.current.start());
|
||||
await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch-lost", id: 7, reason: expect.stringContaining("плохой пакет") }));
|
||||
expect(result.current.busy).toBe(false);
|
||||
const n = h.count("GET /batches/7");
|
||||
await sleep(50);
|
||||
expect(h.count("GET /batches/7")).toBe(n);
|
||||
});
|
||||
}
|
||||
|
||||
it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => {
|
||||
const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } });
|
||||
const { result } = refreshHook();
|
||||
await act(() => result.current.start());
|
||||
await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch-lost", reason: "хаб не отвечает" }));
|
||||
expect(h.count("GET /batches/7")).toBe(5);
|
||||
await sleep(50);
|
||||
expect(h.count("GET /batches/7")).toBe(5);
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("пакет с ошибкой команды — текст ошибки; агент офлайн — предупреждение в событии", async () => {
|
||||
let polls = 0;
|
||||
hub({
|
||||
"GET /agent": () => json(200, { online: false, last_seen_at: null, warning: null }),
|
||||
"GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done", { status: "failed", error: "Jira 500" })),
|
||||
});
|
||||
const { result } = refreshHook();
|
||||
await act(() => result.current.start());
|
||||
await waitFor(() => expect(result.current.event).toMatchObject({ kind: "batch", agentOffline: true }));
|
||||
await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" }));
|
||||
});
|
||||
|
||||
it("«Обновить из Jira» при недоступном хабе — событие hub-down", async () => {
|
||||
hub({ "GET /agent": () => { throw new TypeError("Failed to fetch"); } });
|
||||
const { result } = refreshHook();
|
||||
await act(() => result.current.start());
|
||||
expect(result.current.event).toEqual({ kind: "hub-down" });
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("T10.6: незавершённый пакет viewer без refresh не подхватывается", async () => {
|
||||
const h = hub({
|
||||
"GET /batches": () => json(200, { batches: [{ id: 7, author: "viewer", status: "running" }] }),
|
||||
"GET /batches/:id": () => batch("running", { type: "ensure_fields" }),
|
||||
});
|
||||
const { result } = refreshHook();
|
||||
await waitFor(() => expect(h.count("GET /batches/7")).toBe(1));
|
||||
await sleep(50);
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(h.count("GET /batches/7")).toBe(1); // только проверка типа, опроса нет
|
||||
});
|
||||
|
||||
it("401 на запуске пакета с текущим токеном — токен сброшен", async () => {
|
||||
hub({ "GET /agent": () => json(401, { error: "unauthorized" }) });
|
||||
const { result } = refreshHook();
|
||||
await act(() => result.current.start());
|
||||
expect(getToken()).toBe("");
|
||||
});
|
||||
|
||||
it("запоздавший 401 прежнему токену не сбрасывает новый токен", async () => {
|
||||
const slow = deferred<Response>();
|
||||
hub({ "GET /agent": () => slow.promise });
|
||||
const { result } = refreshHook();
|
||||
let run!: Promise<void>;
|
||||
act(() => { run = result.current.start(); });
|
||||
setToken("new-tok");
|
||||
slow.resolve(json(401, { error: "unauthorized" }));
|
||||
await act(() => run);
|
||||
expect(getToken()).toBe("new-tok");
|
||||
});
|
||||
|
||||
it("по завершении инвалидируются board, issues, issue, projects", async () => {
|
||||
let polls = 0;
|
||||
const h = hub({ "GET /batches/:id": () => (++polls < 2 ? batch("running") : batch("done")) });
|
||||
const { result } = renderHook(() => ({ r: useJiraRefresh(), b: useBoard("AWPCA"), i: useIssues({}) }), { wrapper: wrap() });
|
||||
await waitFor(() => expect(result.current.b.data).toBeDefined());
|
||||
await waitFor(() => expect(result.current.i.data).toBeDefined());
|
||||
await act(() => result.current.r.start());
|
||||
await waitFor(() => expect(result.current.r.event).toMatchObject({ kind: "batch-done" }));
|
||||
await waitFor(() => expect(h.count("GET /board")).toBe(2));
|
||||
await waitFor(() => expect(h.count("GET /issues")).toBe(2));
|
||||
});
|
||||
|
||||
it("инвалидируются ключи board, issues, issue, projects (шпион)", async () => {
|
||||
const client = makeQueryClient();
|
||||
const spy = vi.spyOn(client, "invalidateQueries");
|
||||
hub({ "GET /batches/:id": () => batch("done") });
|
||||
const { result } = refreshHook(client);
|
||||
await act(() => result.current.start());
|
||||
await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 7 }));
|
||||
const keys = spy.mock.calls.map(([f]) => (f as { queryKey: unknown[] }).queryKey[0]);
|
||||
expect(keys).toEqual(expect.arrayContaining(["board", "issues", "issue", "projects"]));
|
||||
});
|
||||
|
||||
it("track(9) опрашивает пакет 9 без POST /refresh", async () => {
|
||||
const h = hub({ "GET /batches/:id": () => json(200, { id: 9, title: "x", author: "viewer", created_at: "", cancelled_at: null, status: "done", counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, status: "done" }] }) });
|
||||
const { result } = refreshHook();
|
||||
await waitFor(() => expect(h.count("GET /batches")).toBe(1));
|
||||
act(() => result.current.track(9));
|
||||
expect(result.current.busy).toBe(true);
|
||||
await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 9 }));
|
||||
expect(h.count("POST /refresh")).toBe(0);
|
||||
expect(h.count("GET /batches/9")).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("batch-done исчезает через 10 с", async () => {
|
||||
hub({ "GET /batches/:id": () => batch("done") });
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true }); // время идёт и само (опрос, waitFor), но сдвигается и вручную
|
||||
const { result } = refreshHook();
|
||||
await act(() => result.current.start());
|
||||
await waitFor(() => expect(result.current.event).toEqual({ kind: "batch-done", id: 7 }));
|
||||
await act(async () => { vi.advanceTimersByTime(9_800); });
|
||||
expect(result.current.event).toEqual({ kind: "batch-done", id: 7 });
|
||||
await act(async () => { vi.advanceTimersByTime(200); });
|
||||
expect(result.current.event).toBeNull();
|
||||
});
|
||||
|
||||
it("переход между страницами во время пакета: один POST, один опрос, busy до завершения; по завершении board перечитан", async () => {
|
||||
let finished = false;
|
||||
const h = hub({ "GET /batches/:id": () => (finished ? batch("done") : batch("running")) });
|
||||
const client = makeQueryClient();
|
||||
let r!: JiraRefresh;
|
||||
const Probe = () => { r = useJiraRefresh(); return null; };
|
||||
const Page = () => { useBoard("AWPCA"); return null; }; // «страница»: монтируется и размонтируется, провайдер — один
|
||||
const ui = (page: boolean) => (
|
||||
<QueryClientProvider client={client}><RefreshProvider pollMs={10}><Probe />{page && <Page />}</RefreshProvider></QueryClientProvider>);
|
||||
const view = render(ui(true));
|
||||
await waitFor(() => expect(h.count("GET /board")).toBe(1));
|
||||
await act(() => r.start());
|
||||
view.rerender(ui(false));
|
||||
expect(r.busy).toBe(true);
|
||||
view.rerender(ui(true));
|
||||
await waitFor(() => expect(h.count("GET /board")).toBeGreaterThanOrEqual(1));
|
||||
await sleep(40);
|
||||
expect(h.count("POST /refresh")).toBe(1);
|
||||
expect(h.maxInFlight("GET /batches/7")).toBe(1);
|
||||
expect(r.busy).toBe(true);
|
||||
const boards = h.count("GET /board");
|
||||
finished = true;
|
||||
await waitFor(() => expect(r.busy).toBe(false));
|
||||
expect(r.event).toMatchObject({ kind: "batch-done" });
|
||||
await waitFor(() => expect(h.count("GET /board")).toBe(boards + 1));
|
||||
});
|
||||
});
|
||||
20
web/src/shell/refresh.ts
Normal file
20
web/src/shell/refresh.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { AgentView } from "../api/types.ts";
|
||||
import type { RefreshEvent } from "./refreshStatus.ts";
|
||||
|
||||
// обновление из Jira: состояние живёт в оболочке (RefreshProvider), страницы и верхняя панель только читают его
|
||||
export interface JiraRefresh {
|
||||
event: RefreshEvent | null;
|
||||
busy: boolean; // запрос /refresh в пути или пакет выполняется
|
||||
agent: AgentView | null;
|
||||
start(): Promise<void>;
|
||||
track(batchId: number): void; // опрашивать уже поставленный пакет (форма «Новый проект»)
|
||||
}
|
||||
|
||||
export const RefreshContext = createContext<JiraRefresh | null>(null);
|
||||
|
||||
export function useJiraRefresh(): JiraRefresh {
|
||||
const v = useContext(RefreshContext);
|
||||
if (!v) throw new Error("useJiraRefresh вызван вне RefreshProvider: оберните приложение в <RefreshProvider>");
|
||||
return v;
|
||||
}
|
||||
20
web/src/shell/refreshStatus.test.ts
Normal file
20
web/src/shell/refreshStatus.test.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { refreshText } from "./refreshStatus.ts";
|
||||
|
||||
describe("refreshText", () => {
|
||||
it("пакет: офлайн, очередь, остаток, повтор", () => {
|
||||
expect(refreshText({ kind: "batch", id: 7, phase: "queued", remaining: 12, agentOffline: true, retrying: false }))
|
||||
.toEqual({ text: "агент офлайн — пакет выполнится, когда он выйдет на связь; пакет #7 в очереди, осталось ~12 с…", tone: "info" });
|
||||
expect(refreshText({ kind: "batch", id: 7, phase: "running", remaining: 0, agentOffline: false, retrying: true }))
|
||||
.toEqual({ text: "пакет #7 выполняется… (хаб не отвечает, повтор через 3 с)", tone: "warning" });
|
||||
});
|
||||
it("исходы и ошибки", () => {
|
||||
expect(refreshText({ kind: "batch-done", id: 7 })).toEqual({ text: "пакет #7 выполнен", tone: "success" });
|
||||
expect(refreshText({ kind: "batch-failed", id: 7, error: "429" })).toEqual({ text: "пакет #7 не выполнен: 429", tone: "error" });
|
||||
expect(refreshText({ kind: "batch-cancelled", id: 7 })).toEqual({ text: "пакет #7 отменён", tone: "warning" });
|
||||
expect(refreshText({ kind: "batch-lost", id: 7, reason: "пакета нет в хабе" }).text)
|
||||
.toBe("пакет #7: пакета нет в хабе — опрос остановлен, «Обновить из Jira» снова доступна");
|
||||
expect(refreshText({ kind: "hub-down" })).toEqual({ text: "хаб не отвечает — пакет не поставлен", tone: "error" });
|
||||
expect(refreshText({ kind: "error", message: "boom" })).toEqual({ text: "boom", tone: "error" });
|
||||
});
|
||||
});
|
||||
32
web/src/shell/refreshStatus.ts
Normal file
32
web/src/shell/refreshStatus.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export const POLL_MS = 3000; // период опроса пакета, мс (как у links.html)
|
||||
export const MAX_POLL_ERRORS = 5; // столько ошибок «хаб не отвечает» подряд — и опрос пакета останавливается (M-4)
|
||||
export const DONE_VISIBLE_MS = 10_000; // «пакет выполнен» гаснет через это время
|
||||
|
||||
// событие обновления из Jira: ход пакета и ошибки его постановки; показывает верхняя панель оболочки
|
||||
export type RefreshEvent =
|
||||
| { kind: "batch"; id: number; phase: "queued" | "running"; remaining: number; agentOffline: boolean; retrying: boolean }
|
||||
| { kind: "batch-done"; id: number }
|
||||
| { kind: "batch-failed"; id: number; error: string }
|
||||
| { kind: "batch-cancelled"; id: number }
|
||||
| { kind: "batch-lost"; id: number; reason: string }
|
||||
| { kind: "hub-down" }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export type RefreshTone = "info" | "success" | "error" | "warning";
|
||||
|
||||
export function refreshText(e: RefreshEvent): { text: string; tone: RefreshTone } {
|
||||
switch (e.kind) {
|
||||
case "batch": {
|
||||
const off = e.agentOffline ? "агент офлайн — пакет выполнится, когда он выйдет на связь; " : "";
|
||||
const left = e.remaining ? `, осталось ~${e.remaining} с` : "";
|
||||
const retry = e.retrying ? ` (хаб не отвечает, повтор через ${POLL_MS / 1000} с)` : "";
|
||||
return { text: `${off}пакет #${e.id} ${e.phase === "queued" ? "в очереди" : "выполняется"}${left}…${retry}`, tone: e.retrying ? "warning" : "info" };
|
||||
}
|
||||
case "batch-done": return { text: `пакет #${e.id} выполнен`, tone: "success" };
|
||||
case "batch-failed": return { text: `пакет #${e.id} не выполнен: ${e.error}`, tone: "error" };
|
||||
case "batch-cancelled": return { text: `пакет #${e.id} отменён`, tone: "warning" };
|
||||
case "batch-lost": return { text: `пакет #${e.id}: ${e.reason} — опрос остановлен, «Обновить из Jira» снова доступна`, tone: "error" };
|
||||
case "hub-down": return { text: "хаб не отвечает — пакет не поставлен", tone: "error" };
|
||||
case "error": return { text: e.message, tone: "error" };
|
||||
}
|
||||
}
|
||||
35
web/src/test/batchHub.ts
Normal file
35
web/src/test/batchHub.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { json, mockFetch } from "./fetchMock.ts";
|
||||
|
||||
// хаб в памяти для тестов обновления из Jira: снимок, список пакетов, пакет по id; счётчики запросов
|
||||
export const fixture = (name: string) => readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", name), "utf8");
|
||||
export const SNAP = JSON.parse(fixture("hub-basic.board.json"));
|
||||
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
export const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
|
||||
export function hub(over: Partial<Record<string, (url: URL, body: unknown) => Response | Promise<Response>>> = {}) {
|
||||
const calls: string[] = [];
|
||||
const inFlight: Record<string, number> = {}, maxInFlight: Record<string, number> = {};
|
||||
const fetch = mockFetch(async (method, url, body) => {
|
||||
const k = `${method} ${url.pathname}`;
|
||||
calls.push(k);
|
||||
inFlight[k] = (inFlight[k] ?? 0) + 1;
|
||||
maxInFlight[k] = Math.max(maxInFlight[k] ?? 0, inFlight[k]);
|
||||
try {
|
||||
const h = over[k] ?? over[`${method} ${url.pathname.replace(/\d+$/, ":id")}`];
|
||||
if (h) return await h(url, body);
|
||||
if (k === "GET /board") return json(200, SNAP);
|
||||
if (k === "GET /issues") return json(200, { issues: [] });
|
||||
if (k === "GET /projects") return json(200, { projects: [] });
|
||||
if (k === "GET /batches") return json(200, { batches: [] });
|
||||
if (k === "GET /agent") return json(200, { online: true, last_seen_at: null, warning: null });
|
||||
if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
|
||||
return json(404, { error: "not_found" });
|
||||
} finally { inFlight[k] -= 1; }
|
||||
});
|
||||
return { fetch, calls, count: (k: string) => calls.filter((c) => c === k).length, maxInFlight: (k: string) => maxInFlight[k] ?? 0 };
|
||||
}
|
||||
|
||||
export const batch = (status: string, cmd: Record<string, unknown> = { status: "done" }) =>
|
||||
json(200, { id: 7, title: "Обновление", author: "viewer", created_at: "", cancelled_at: null, status, counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, ...cmd }] });
|
||||
@ -2,6 +2,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||
import { RefreshProvider } from "../shell/RefreshProvider.tsx";
|
||||
import { makeQueryClient } from "../api/queryClient.ts";
|
||||
|
||||
// компонент под QueryClient и data-роутером в памяти: path — шаблон маршрута (для useParams), route — открытый адрес
|
||||
@ -9,5 +10,5 @@ export function renderWithProviders(ui: ReactElement, { route = "/", path = "*"
|
||||
const client = makeQueryClient();
|
||||
const router = createMemoryRouter([{ path, element: ui }, { path: "*", element: <p>другая страница</p> }],
|
||||
{ initialEntries: [route] });
|
||||
return { client, router, ...render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>) };
|
||||
return { client, router, ...render(<QueryClientProvider client={client}><RefreshProvider><RouterProvider router={router} /></RefreshProvider></QueryClientProvider>) };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user