From 5a142f772c7515cd87dc790bdaaa0cc21a9f60a2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 03:17:13 +0400 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1:=20=D0=B2=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20Task=2010=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дерево не раскрывает предков повторно после ручного сворачивания, предел опроса вложения, //host не ссылка, регистр схем page:/attachment:, тесты дерева, загрузки, плашки панели, 422/404. Co-Authored-By: Claude Opus 5.5 --- web/src/modules/wiki/PageView.tsx | 4 +- web/src/modules/wiki/SyncStatus.test.tsx | 44 ++++++++++++ web/src/modules/wiki/Tree.test.tsx | 48 +++++++++++++ web/src/modules/wiki/Tree.tsx | 6 +- web/src/modules/wiki/WikiPage.test.tsx | 29 ++++++++ web/src/modules/wiki/download.test.tsx | 82 +++++++++++++++++++++++ web/src/modules/wiki/download.ts | 4 ++ web/src/modules/wiki/urlTransform.test.ts | 6 +- web/src/modules/wiki/urlTransform.ts | 6 +- 9 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 web/src/modules/wiki/SyncStatus.test.tsx create mode 100644 web/src/modules/wiki/Tree.test.tsx create mode 100644 web/src/modules/wiki/download.test.tsx diff --git a/web/src/modules/wiki/PageView.tsx b/web/src/modules/wiki/PageView.tsx index 974086c..bce6868 100644 --- a/web/src/modules/wiki/PageView.tsx +++ b/web/src/modules/wiki/PageView.tsx @@ -25,8 +25,8 @@ export function PageView({ project, code, hrefOf }: { project: string; code: str const components = useMemo(() => ({ a({ href, children }: ComponentProps<"a">) { if (!href) return <>{children}; - if (href.startsWith("page:")) return {children}; - if (href.startsWith("attachment:")) return {children}; + if (/^page:/i.test(href)) return {children}; + if (/^attachment:/i.test(href)) return {children}; if (/^https?:/i.test(href)) return {children}; return {children}; }, diff --git a/web/src/modules/wiki/SyncStatus.test.tsx b/web/src/modules/wiki/SyncStatus.test.tsx new file mode 100644 index 0000000..be543b8 --- /dev/null +++ b/web/src/modules/wiki/SyncStatus.test.tsx @@ -0,0 +1,44 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "../../api/queryClient.ts"; +import { setToken } from "../../auth/token.ts"; +import { json, mockFetch } from "../../test/fetchMock.ts"; +import { POLL_MS } from "./api/queries.ts"; +import { SidebarStatus } from "./SyncStatus.tsx"; + +const tick = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); }); +const base = { root: "DOC-1", root_title: "К", synced_at: "2026-10-20T10:00:00+00:00", pages: 3, error: null }; + +describe("SidebarStatus вики", () => { + beforeEach(() => { setToken("tok"); vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); + + it("опрос раз в 3 с, пока пакет идёт; при терминальном статусе опрос прекращается", async () => { + let calls = 0; + mockFetch((_m, url) => { + if (url.pathname === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "A", created_at: "", wiki_root: "DOC-1" }] }); + calls++; + return json(200, { ...base, batch: { id: 5, status: calls < 3 ? "running" : "done" } }); + }); + render(); + await tick(10); // сначала список проектов, затем статус + expect(screen.getByRole("status")).toHaveTextContent("пакет #5 выполняется"); + expect(calls).toBe(1); + await tick(POLL_MS); + expect(calls).toBe(2); + await tick(POLL_MS); + expect(calls).toBe(3); // третий ответ — done + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.getByText(/^Вики на/)).toBeInTheDocument(); + await tick(5 * POLL_MS); + expect(calls).toBe(3); + }); + + it("проект без корня: запросов статуса нет", async () => { + const f = mockFetch((_m, url) => url.pathname === "/projects" ? json(200, { projects: [{ label: "P", name: "P", created_at: "", wiki_root: null }] }) : json(409, {})); + render(); + await tick(0); + expect(f.mock.calls.some(([u]) => String(u).startsWith("/wiki/status"))).toBe(false); + }); +}); diff --git a/web/src/modules/wiki/Tree.test.tsx b/web/src/modules/wiki/Tree.test.tsx new file mode 100644 index 0000000..de4ecc7 --- /dev/null +++ b/web/src/modules/wiki/Tree.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WikiNode } from "./api/queries.ts"; +import { Tree } from "./Tree.tsx"; + +const N = (code: string, depth: number, parent: string | null): WikiNode => ({ code, title: `Стр ${code}`, depth, parent_code: parent, size: 1 }); +const NODES = [N("A", 0, null), N("B", 1, "A"), N("C", 2, "B"), N("D", 1, "A")]; +const item = (code: string) => screen.getByRole("treeitem", { name: `Стр ${code}` }); + +describe("Tree", () => { + it("родителя открытой страницы можно свернуть клавишей и шевроном, он остаётся свёрнутым", async () => { + const { container } = render( {}} />); + expect(item("C")).toBeInTheDocument(); // предки выбранной страницы раскрыты + item("B").focus(); + await userEvent.keyboard("{ArrowLeft}"); + expect(item("B")).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("treeitem", { name: "Стр C" })).toBeNull(); + await userEvent.click(item("B").querySelector(".wiki-tree__chevron")!); // раскрыть шевроном + expect(item("B")).toHaveAttribute("aria-expanded", "true"); + await userEvent.click(item("B").querySelector(".wiki-tree__chevron")!); // и свернуть шевроном + expect(item("B")).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(item("A").querySelector(".wiki-tree__chevron")!); // корень тоже сворачивается + expect(container.querySelectorAll('[role="treeitem"]')).toHaveLength(1); + }); + + it("клавиатура: стрелки, Home/End, вправо входит в узел, Enter открывает страницу", async () => { + const onOpen = vi.fn(); + render(); + item("A").focus(); + await userEvent.keyboard("{ArrowDown}"); + expect(item("B")).toHaveFocus(); + await userEvent.keyboard("{ArrowRight}"); // B свёрнут → раскрыть + expect(item("B")).toHaveAttribute("aria-expanded", "true"); + await userEvent.keyboard("{ArrowRight}"); // раскрыт → к первому потомку + expect(item("C")).toHaveFocus(); + await userEvent.keyboard("{ArrowLeft}"); // лист → к родителю + expect(item("B")).toHaveFocus(); + await userEvent.keyboard("{End}"); + expect(item("D")).toHaveFocus(); + await userEvent.keyboard("{ArrowUp}"); + expect(item("C")).toHaveFocus(); + await userEvent.keyboard("{Home}"); + expect(item("A")).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + expect(onOpen).toHaveBeenLastCalledWith("A"); + }); +}); diff --git a/web/src/modules/wiki/Tree.tsx b/web/src/modules/wiki/Tree.tsx index 460cf7e..4429652 100644 --- a/web/src/modules/wiki/Tree.tsx +++ b/web/src/modules/wiki/Tree.tsx @@ -12,13 +12,13 @@ export function Tree({ nodes, selected, onOpen }: { nodes: WikiNode[]; selected: const [focus, setFocus] = useState(null); const refs = useRef(new Map()); - // предки выбранной страницы раскрыты + // предки выбранной страницы раскрываются при её смене; ручное сворачивание потом не откатывается (зависимости без open) useEffect(() => { if (!selected) return; const up: string[] = []; for (let n = byCode.get(selected)?.parent_code; n && !up.includes(n); n = byCode.get(n)?.parent_code ?? null) up.push(n); - if (up.some((c) => !open.has(c))) setOpen((o) => new Set([...o, ...up])); - }, [selected, byCode, open]); + if (up.length) setOpen((o) => (up.every((c) => o.has(c)) ? o : new Set([...o, ...up]))); + }, [selected, byCode]); const visible = useMemo(() => nodes.filter((n) => { for (let p = n.parent_code, guard = 0; p && guard < nodes.length; p = byCode.get(p)?.parent_code ?? null, guard++) { diff --git a/web/src/modules/wiki/WikiPage.test.tsx b/web/src/modules/wiki/WikiPage.test.tsx index 9b80c7b..b6bec20 100644 --- a/web/src/modules/wiki/WikiPage.test.tsx +++ b/web/src/modules/wiki/WikiPage.test.tsx @@ -65,6 +65,35 @@ describe("страница «Вики»", () => { expect(patch.body).toMatchObject({ wiki_root: "DOC-001234" }); }); + it("422 при привязке корня — текст ошибки у поля", async () => { + hub((method, url) => { + if (url.pathname === "/wiki/status") return json(409, { error: "no_root", detail: "нет корня" }); + if (method === "PATCH") return json(422, { error: "bad_request", detail: "ожидается код DOC-… или ссылка на документ Eva" }); + }); + renderWithProviders(, { route: "/wiki?project=sfm_kedr", path: "/wiki" }); + await userEvent.type(await screen.findByLabelText("Корень вики (DOC-… или ссылка)"), "ерунда"); + await userEvent.click(screen.getByRole("button", { name: "Привязать" })); + expect(await screen.findByText("ожидается код DOC-… или ссылка на документ Eva")).toBeInTheDocument(); + expect(screen.getByLabelText("Корень вики (DOC-… или ссылка)")).toHaveAttribute("aria-invalid", "true"); + }); + + it("404 страницы — сообщение об ошибке, дерево на месте", async () => { + hub((_m, url) => url.pathname === "/wiki/pages/DOC-9" ? json(404, { error: "not_found", detail: "страница DOC-9 не найдена" }) : undefined); + open("/wiki?project=AWPCA&page=DOC-9"); + expect(await screen.findByText(/страница DOC-9 не найдена/)).toBeInTheDocument(); + expect(screen.getByRole("tree")).toBeInTheDocument(); + }); + + it("ссылки //host не уводят на внешний сайт, PAGE: открывается как page:", async () => { + hub((_m, url) => url.pathname === "/wiki/pages/DOC-2" + ? json(200, page("DOC-2", "[внешняя](//evil.example/x) [вторая](PAGE:DOC-3)")) : undefined); + open("/wiki?project=AWPCA&page=DOC-2"); + const article = await screen.findByRole("article"); + await within(article).findByText("внешняя"); + expect(article.querySelector('a[href^="//"]')).toBeNull(); + expect(within(article).getByRole("link", { name: "вторая" })).toHaveAttribute("href", "/wiki?project=AWPCA&page=DOC-3"); + }); + it("поиск заменяет дерево", async () => { const calls = hub((_m, url) => url.pathname === "/wiki/search" ? json(200, { results: [{ code: "DOC-3", title: "Регламент", path: "p", snippet: "порядок согласования" }] }) : undefined); diff --git a/web/src/modules/wiki/download.test.tsx b/web/src/modules/wiki/download.test.tsx new file mode 100644 index 0000000..37949f7 --- /dev/null +++ b/web/src/modules/wiki/download.test.tsx @@ -0,0 +1,82 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setToken } from "../../auth/token.ts"; +import { json, mockFetch } from "../../test/fetchMock.ts"; +import { ATT_MAX_POLLS, ATT_POLL_MS, ATT_TIMEOUT_TEXT, useWikiDownload } from "./download.ts"; + +const st = (state: string, extra: object = {}) => ({ id: 7, name: "схема.txt", state, error: null, size: 3, ...extra }); +const flush = () => act(async () => { await vi.advanceTimersByTimeAsync(0); }); +const tick = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); }); + +describe("useWikiDownload", () => { + let clicked: string[]; + beforeEach(() => { + setToken("tok"); + vi.useFakeTimers(); + clicked = []; + URL.createObjectURL = vi.fn(() => "blob:fake"); + URL.revokeObjectURL = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { clicked.push(this.download); }); + }); + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + + it("fetching → опрос раз в 2 с → ready → файл скачан один раз под своим именем", async () => { + let gets = 0; + const f = mockFetch((m, url) => { + if (m === "POST") return json(202, st("fetching")); + if (url.pathname.endsWith("/content")) return new Response("txt"); + gets++; + return json(200, st(gets < 2 ? "fetching" : "ready")); + }); + const { result } = renderHook(() => useWikiDownload("AWPCA", "DOC-1")); + act(() => result.current.start("7")); + await flush(); + expect(result.current.status("7").busy).toBe(true); + await tick(ATT_POLL_MS); + expect(gets).toBe(1); + await tick(ATT_POLL_MS); + expect(clicked).toEqual(["схема.txt"]); + expect(result.current.status("7")).toEqual({ busy: false, error: null }); + expect(f.mock.calls.some(([u]) => String(u) === "/wiki/attachments/7/content")).toBe(true); + await tick(10 * ATT_POLL_MS); + expect(gets).toBe(2); // после ready опроса нет + }); + + it("failed — текст ошибки, файла нет", async () => { + mockFetch((m) => (m === "POST" ? json(202, st("fetching")) : json(200, st("failed", { error: "Eva: 404" })))); + const { result } = renderHook(() => useWikiDownload("AWPCA", "DOC-1")); + act(() => result.current.start("7")); + await tick(ATT_POLL_MS); + expect(result.current.status("7")).toEqual({ busy: false, error: "Eva: 404" }); + expect(clicked).toEqual([]); + }); + + it("размонтирование и смена страницы останавливают опрос", async () => { + let gets = 0; + mockFetch((m) => { if (m === "POST") return json(202, st("fetching")); gets++; return json(200, st("fetching")); }); + const { result, rerender, unmount } = renderHook(({ scope }) => useWikiDownload("AWPCA", scope), { initialProps: { scope: "DOC-1" } }); + act(() => result.current.start("7")); + await tick(ATT_POLL_MS); + expect(gets).toBe(1); + rerender({ scope: "DOC-2" }); // другая страница + expect(result.current.status("7").busy).toBe(false); + await tick(5 * ATT_POLL_MS); + expect(gets).toBe(1); + act(() => result.current.start("7")); + await tick(ATT_POLL_MS); + expect(gets).toBe(2); + unmount(); + await tick(5 * ATT_POLL_MS); + expect(gets).toBe(2); + }); + + it("опрос ограничен: после предела — понятная ошибка, запросы прекращаются", async () => { + let gets = 0; + mockFetch((m) => { if (m === "POST") return json(202, st("fetching")); gets++; return json(200, st("fetching")); }); + const { result } = renderHook(() => useWikiDownload("AWPCA", "DOC-1")); + act(() => result.current.start("7")); + await tick(ATT_POLL_MS * (ATT_MAX_POLLS + 5)); + expect(result.current.status("7")).toEqual({ busy: false, error: ATT_TIMEOUT_TEXT }); + expect(gets).toBe(ATT_MAX_POLLS); + }); +}); diff --git a/web/src/modules/wiki/download.ts b/web/src/modules/wiki/download.ts index 2298957..acfcd86 100644 --- a/web/src/modules/wiki/download.ts +++ b/web/src/modules/wiki/download.ts @@ -3,6 +3,8 @@ import { api, ApiError } from "../../api/client.ts"; import type { WikiAttachmentState } from "./api/queries.ts"; export const ATT_POLL_MS = 2000; +export const ATT_MAX_POLLS = 60; // 60 × 2 с = 2 минуты: дольше агент, скорее всего, не ответит +export const ATT_TIMEOUT_TEXT = "агент не ответил за 2 минуты — повторите позже"; export interface DownloadStatus { busy: boolean; error: string | null } const IDLE: DownloadStatus = { busy: false, error: null }; @@ -45,6 +47,7 @@ export function useWikiDownload(project: string, scope: string) { const my = gen.current, alive = () => gen.current === my; const fail = (error: string) => { if (alive()) put(id, { busy: false, error }); }; const base = `/wiki/attachments/${encodeURIComponent(id)}`; + let polls = 0; const handle = async (st: WikiAttachmentState): Promise => { if (!alive()) return; if (st.state === "ready") { @@ -53,6 +56,7 @@ export function useWikiDownload(project: string, scope: string) { save(blob, (st.name ?? "").trim() || `attachment-${id}`); put(id, IDLE); } else if (st.state === "fetching") { + if (++polls > ATT_MAX_POLLS) return fail(ATT_TIMEOUT_TEXT); const t = setTimeout(() => { timers.current.delete(t); if (!alive()) return; diff --git a/web/src/modules/wiki/urlTransform.test.ts b/web/src/modules/wiki/urlTransform.test.ts index a3ea268..5c6cfac 100644 --- a/web/src/modules/wiki/urlTransform.test.ts +++ b/web/src/modules/wiki/urlTransform.test.ts @@ -5,7 +5,11 @@ describe("wikiUrlTransform", () => { it("пропускает page:, attachment:, http(s) и относительные", () => { for (const u of ["page:DOC-2", "attachment:7", "https://eva.test/x", "http://a/b", "/rel/path", "#h", "a/b?c=1"]) expect(wikiUrlTransform(u)).toBe(u); }); + it("page: и attachment: с мусором проходят как есть (разбор — в компоненте), но чужая схема после них — нет", () => { + for (const u of ["page:", "page:../../x", "attachment:abc", "PAGE:DOC-1", "Attachment:7"]) expect(wikiUrlTransform(u)).toBe(u); + expect(wikiUrlTransform("page:javascript:1")).toBe("page:javascript:1"); // это page: с кодом «javascript:1», не схема javascript + }); it("режет javascript:, data:, vbscript:, прочие схемы и обход управляющими символами", () => { - for (const u of ["javascript:alert(1)", "JaVaScRiPt:1", "data:text/html,x", "vbscript:x", "file:///etc/passwd", "java\nscript:alert(1)", " \tjavascript:1"]) expect(wikiUrlTransform(u)).toBe(""); + for (const u of ["javascript:alert(1)", "JaVaScRiPt:1", "data:text/html,x", "vbscript:x", "file:///etc/passwd", "java\nscript:alert(1)", " \tjavascript:1", "//evil.example/x", "/\\evil.example", " //evil", "\u0001javascript:1", "java script:1", "\u0001 javascript:alert(1)"]) expect(wikiUrlTransform(u)).toBe(""); }); }); diff --git a/web/src/modules/wiki/urlTransform.ts b/web/src/modules/wiki/urlTransform.ts index 6f89f82..8ef6d42 100644 --- a/web/src/modules/wiki/urlTransform.ts +++ b/web/src/modules/wiki/urlTransform.ts @@ -1,12 +1,14 @@ // адреса в Markdown вики: пропускаются внутренние page:/attachment:, http(s) и относительные; остальные схемы -// (javascript:, data:, vbscript:, file: …) заменяются пустой строкой. Управляющие символы и пробелы в схеме +// (javascript:, data:, vbscript:, file: …) и адреса вида //host заменяются пустой строкой. Управляющие символы и пробелы в схеме // браузер игнорирует («java\nscript:»), поэтому схема определяется после их удаления. const ALLOWED = new Set(["page", "attachment", "http", "https"]); // eslint-disable-next-line no-control-regex const IGNORED = /[\u0000- \u007f]/g; export function wikiUrlTransform(url: string): string { - const m = /^([a-z][a-z0-9+.-]*):/i.exec(url.replace(IGNORED, "")); + const bare = url.replace(IGNORED, ""); + if (/^[/\\]{2}/.test(bare)) return ""; // «//host» и «/\host» — адрес без схемы, но на внешний сайт + const m = /^([a-z][a-z0-9+.-]*):/i.exec(bare); if (!m) return url; // схемы нет — относительный адрес return ALLOWED.has(m[1].toLowerCase()) ? url : ""; }