Веб: вики — правки по ревью Task 10 (#91)
Дерево не раскрывает предков повторно после ручного сворачивания, предел опроса вложения, //host не ссылка, регистр схем page:/attachment:, тесты дерева, загрузки, плашки панели, 422/404. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
48ca56c07f
commit
5a142f772c
@ -25,8 +25,8 @@ export function PageView({ project, code, hrefOf }: { project: string; code: str
|
||||
const components = useMemo<Components>(() => ({
|
||||
a({ href, children }: ComponentProps<"a">) {
|
||||
if (!href) return <>{children}</>;
|
||||
if (href.startsWith("page:")) return <Link to={hrefOf(decode(href.slice(5)))}>{children}</Link>;
|
||||
if (href.startsWith("attachment:")) return <AttachmentLink id={decode(href.slice(11))} dl={dl}>{children}</AttachmentLink>;
|
||||
if (/^page:/i.test(href)) return <Link to={hrefOf(decode(href.slice(5)))}>{children}</Link>;
|
||||
if (/^attachment:/i.test(href)) return <AttachmentLink id={decode(href.slice(11))} dl={dl}>{children}</AttachmentLink>;
|
||||
if (/^https?:/i.test(href)) return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>;
|
||||
return <a href={href}>{children}</a>;
|
||||
},
|
||||
|
||||
44
web/src/modules/wiki/SyncStatus.test.tsx
Normal file
44
web/src/modules/wiki/SyncStatus.test.tsx
Normal file
@ -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(<QueryClientProvider client={makeQueryClient()}><SidebarStatus project="AWPCA" compact={false} /></QueryClientProvider>);
|
||||
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(<QueryClientProvider client={makeQueryClient()}><SidebarStatus project="P" compact={false} /></QueryClientProvider>);
|
||||
await tick(0);
|
||||
expect(f.mock.calls.some(([u]) => String(u).startsWith("/wiki/status"))).toBe(false);
|
||||
});
|
||||
});
|
||||
48
web/src/modules/wiki/Tree.test.tsx
Normal file
48
web/src/modules/wiki/Tree.test.tsx
Normal file
@ -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(<Tree nodes={NODES} selected="C" onOpen={() => {}} />);
|
||||
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(<Tree nodes={NODES} selected={null} onOpen={onOpen} />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@ -12,13 +12,13 @@ export function Tree({ nodes, selected, onOpen }: { nodes: WikiNode[]; selected:
|
||||
const [focus, setFocus] = useState<string | null>(null);
|
||||
const refs = useRef(new Map<string, HTMLDivElement>());
|
||||
|
||||
// предки выбранной страницы раскрыты
|
||||
// предки выбранной страницы раскрываются при её смене; ручное сворачивание потом не откатывается (зависимости без 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++) {
|
||||
|
||||
@ -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(<WikiPage project="sfm_kedr" />, { 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);
|
||||
|
||||
82
web/src/modules/wiki/download.test.tsx
Normal file
82
web/src/modules/wiki/download.test.tsx
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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<void> => {
|
||||
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;
|
||||
|
||||
@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 : "";
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user