Пакет веб-багов: карточка, лента, вики-разметка, состояние модулей (#124) #125

Merged
aep merged 17 commits from feat/124-web-bugs into main 2026-10-26 00:55:02 +03:00
6 changed files with 88 additions and 46 deletions
Showing only changes of commit 43c1f830df - Show all commits

View File

@ -524,7 +524,7 @@ describe("вложения в карточке", () => {
return mockFetch((m, url) => extra?.(m, url) ?? (url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-1650", { ...base, ...over }))));
}
it("раздел «Вложения (2)» после «Описание», новые сверху; размер, автор · дата", async () => {
it("раздел «Вложения (2)» после «Описание», порядок хаба; размер, автор · дата", async () => {
hub({});
open("/jira/issues/AURORA-1650");
const sec = within(await screen.findByTestId("attachments"));
@ -532,12 +532,39 @@ describe("вложения в карточке", () => {
const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent);
expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1);
const rows = sec.getAllByRole("listitem");
expect(rows[0]).toHaveTextContent("ОСВ 60.xlsx");
expect(rows[0]).toHaveTextContent("1,0 КБ");
expect(rows[1]).toHaveTextContent("скрин 1.png");
expect(rows[1]).toHaveTextContent("234,4 КБ");
expect(rows[1]).toHaveTextContent("Иванов Иван · 15.10");
expect(within(rows[1]).getByRole("button", { name: "Скачать" })).toBeInTheDocument();
expect(rows[1]).toHaveTextContent("ОСВ 60.xlsx");
expect(rows[1]).toHaveTextContent("1,0 КБ");
expect(rows[0]).toHaveTextContent("скрин 1.png");
expect(rows[0]).toHaveTextContent("234,4 КБ");
expect(rows[0]).toHaveTextContent("Иванов Иван · 15.10");
expect(within(rows[0]).getByRole("button", { name: "Скачать" })).toBeInTheDocument();
});
it("порядок вложений — как у хаба, без пересортировки по дате", async () => {
hub({ attachments: [att("1", "b.bin", { created: null }), att("2", "a.bin", { created: "2026-10-20T10:00:00.000+0300" })], comments: [] });
open("/jira/issues/AURORA-1650");
const rows = within(await screen.findByTestId("attachments")).getAllByRole("listitem");
expect(rows[0]).toHaveTextContent("b.bin");
expect(rows[1]).toHaveTextContent("a.bin");
});
it("повтор после ошибки: успех в карточке скрывает прежний failed из несвежего ответа хаба", async () => {
let fetched = false;
hub({ attachments: [att("31337", "скрин 1.png", { state: "failed", error: "сбой" })], comments: [] }, (_m, url) => {
if (url.pathname === "/attachments/31337/fetch") { fetched = true; return json(200, { attachment_id: "31337", state: "ready", error: null, command_id: 1, batch_id: 1 }); }
if (url.pathname === "/attachments/31337/content") return new Response("PNG", { status: 200 });
return undefined;
});
URL.createObjectURL = vi.fn(() => "blob:fake");
URL.revokeObjectURL = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
open("/jira/issues/AURORA-1650");
const sec = within(await screen.findByTestId("attachments"));
expect(await sec.findByText("сбой")).toBeInTheDocument();
await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(fetched).toBe(true));
await waitFor(() => expect(sec.queryByText("сбой")).toBeNull());
expect(sec.getByRole("button", { name: "Скачать" })).toBeInTheDocument();
});
it("без поля attachments (старый хаб) раздела нет", async () => {
@ -596,7 +623,7 @@ describe("вложения в карточке", () => {
? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined);
open("/jira/issues/AURORA-1650");
const sec = within(await screen.findByTestId("attachments"));
await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[1]);
await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[0]);
expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument();
await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2);

View File

@ -10,19 +10,13 @@ export type Download = ReturnType<typeof useAttachmentDownload>;
const KIND_ICON = { image: IconImage, sheet: IconSheet, pdf: IconFile, other: IconFile };
const KIND_LABEL = { image: "картинка", sheet: "таблица", pdf: "PDF", other: "файл" };
// новые сверху: по времени Jira, затем по числовому id
export function newestFirst(items: IssueAttachment[]): IssueAttachment[] {
const t = (a: IssueAttachment) => (a.created ? Date.parse(a.created) || 0 : 0);
return [...items].sort((a, b) => t(b) - t(a) || (Number(b.attachment_id) || 0) - (Number(a.attachment_id) || 0));
}
// раздел «Вложения (N)»: значок по типу, имя, размер, «автор · дата», отметка «у хаба», кнопка скачивания
export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Download }) {
return (
<section className="attachments" data-testid="attachments">
<div className="sec-head"><h3>Вложения ({items.length})</h3></div>
<ul className="att-list">
{newestFirst(items).map((a) => <Row key={a.attachment_id} a={a} dl={dl} />)}
{items.map((a) => <Row key={a.attachment_id} a={a} dl={dl} />)}
</ul>
</section>
);
@ -31,9 +25,9 @@ export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Do
function Row({ a, dl }: { a: IssueAttachment; dl: Download }) {
const kind = fileKind(a.mime_type, a.filename);
const Icon = KIND_ICON[kind];
const { busy, error: local } = dl.status(a.attachment_id);
const { busy, error: local, ok } = dl.status(a.attachment_id);
// ошибка: своя (последняя попытка в этой карточке) или команда, закончившаяся failed до открытия карточки
const error = busy ? null : local ?? (a.state === "failed" ? a.error ?? "не удалось скачать вложение" : null);
const error = busy ? null : local ?? (a.state === "failed" && !ok ? a.error ?? "не удалось скачать вложение" : null);
const who = [a.author, a.created ? fmtStamp(a.created) : null].filter(Boolean).join(" · ");
return (
<li className="att-row">

View File

@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeQueryClient } from "../../../../api/queryClient.ts";
import type { IssueAttachment } from "../../api/types.ts";
import { setToken } from "../../../../auth/token.ts";
import { getToken, setToken } from "../../../../auth/token.ts";
import { json, mockFetch } from "../../../../test/fetchMock.ts";
import { useAttachmentDownload } from "./useAttachmentDownload.ts";
@ -38,7 +38,7 @@ describe("useAttachmentDownload", () => {
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
act(() => result.current.start(att));
await flush();
expect(result.current.status("31337")).toEqual({ busy: true, error: null });
expect(result.current.status("31337")).toEqual({ busy: true, error: null, ok: false });
expect(JSON.parse(String(f.mock.calls[0][1]?.body))).toEqual({ author: "web" });
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
expect(polls).toBe(1);
@ -48,7 +48,7 @@ describe("useAttachmentDownload", () => {
expect(URL.revokeObjectURL).not.toHaveBeenCalled(); // I-2: отзыв отложен
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake");
expect(result.current.status("31337")).toEqual({ busy: false, error: null });
expect(result.current.status("31337")).toEqual({ busy: false, error: null, ok: true });
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
expect(polls).toBe(2);
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/content"))).toHaveLength(1);
@ -84,7 +84,7 @@ describe("useAttachmentDownload", () => {
const n = f.mock.calls.length;
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
expect(f.mock.calls.length).toBe(n);
expect(result.current.status("31337")).toEqual({ busy: false, error: null });
expect(result.current.status("31337")).toEqual({ busy: false, error: null, ok: false });
});
it("409 — ошибка с текстом хаба", async () => {
@ -92,20 +92,39 @@ describe("useAttachmentDownload", () => {
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
act(() => result.current.start(att));
await flush();
expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14" });
expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14", ok: false });
expect(clicked).toHaveLength(0);
});
it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => {
let n = 0;
mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404" }) : st("fetching")));
it("401 при опросе — сброс токена, без текста ошибки", async () => {
mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(401, { error: "unauthorized" }));
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
act(() => result.current.start(att));
await flush();
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404" });
expect(getToken()).toBe("");
expect(result.current.status("31337").error).toBeNull();
});
it("сеть недоступна при опросе — «хаб не отвечает»", async () => {
mockFetch((m) => { if (m === "POST") return json(200, st("fetching")); throw new TypeError("fetch failed"); });
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
act(() => result.current.start(att));
await flush();
expect(result.current.status("31337")).toEqual({ busy: true, error: null });
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
expect(result.current.status("31337").error).toBe("хаб не отвечает");
});
it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => {
let n = 0;
mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404", ok: false }) : st("fetching")));
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
act(() => result.current.start(att));
await flush();
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404", ok: false });
act(() => result.current.start(att));
await flush();
expect(result.current.status("31337")).toEqual({ busy: true, error: null, ok: false });
});
});

View File

@ -1,13 +1,13 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { ApiError } from "../../../../api/client.ts";
import { ApiError, AuthError, dropTokenOn, HubDownError } from "../../../../api/client.ts";
import { api } from "../../../../api/client.ts";
import { qk } from "../../api/queries.ts";
import type { AttachmentState, IssueAttachment } from "../../api/types.ts";
export const POLL_MS = 2000;
export interface DownloadStatus { busy: boolean; error: string | null }
const IDLE: DownloadStatus = { busy: false, error: null };
export interface DownloadStatus { busy: boolean; error: string | null; ok: boolean } // ok — успех в этой карточке
const IDLE: DownloadStatus = { busy: false, error: null, ok: false };
// браузер сохраняет файл под своим именем: Blob → временная ссылка download (токен в адресе не появляется)
function save(blob: Blob, filename: string) {
@ -56,7 +56,12 @@ export function useAttachmentDownload(issueKey: string) {
if (mapRef.current[id]?.busy) return;
const my = gen.current;
const alive = () => gen.current === my;
const fail = (error: string) => { if (alive()) put(id, { busy: false, error }); };
const fail = (error: string) => { if (alive()) put(id, { busy: false, error, ok: false }); };
// 401 — экран входа без текста ошибки; сеть/5xx — «хаб не отвечает»; прочее — текст ошибки
const onError = (e: unknown) => {
if (e instanceof AuthError) { dropTokenOn(e); if (alive()) put(id, IDLE); }
else fail(e instanceof HubDownError ? "хаб не отвечает" : errorText(e));
};
// разбор состояния: ready — забрать файл, fetching — подождать и спросить снова, иначе — ошибка
const handle = async (st: AttachmentState): Promise<void> => {
@ -65,13 +70,13 @@ export function useAttachmentDownload(issueKey: string) {
const blob = await api.blob(`/attachments/${encodeURIComponent(id)}/content`);
if (!alive()) return;
save(blob, a.filename);
put(id, IDLE);
put(id, { busy: false, error: null, ok: true });
void qc.invalidateQueries({ queryKey: qk.issue(issueKey) }); // отметка «у хаба»
} else if (st.state === "fetching") {
const t = setTimeout(() => {
timers.current.delete(t);
if (!alive()) return;
api.get<AttachmentState>(`/attachments/${encodeURIComponent(id)}`).then(handle).catch((e) => fail(errorText(e)));
api.get<AttachmentState>(`/attachments/${encodeURIComponent(id)}`).then(handle).catch(onError);
}, POLL_MS);
timers.current.add(t);
} else {
@ -79,9 +84,9 @@ export function useAttachmentDownload(issueKey: string) {
}
};
put(id, { busy: true, error: null });
put(id, { busy: true, error: null, ok: false });
api.post<AttachmentState>(`/attachments/${encodeURIComponent(id)}/fetch`, { author: "web" })
.then(handle).catch((e) => fail(errorText(e)));
.then(handle).catch(onError);
}, [issueKey, put, qc]);
const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]);

View File

@ -89,13 +89,17 @@ describe("parseInline: ссылки на вложения", () => {
expect(parseInline("см. !скрин 1.png|thumbnail!", A)).toEqual([t("см. "), att("31337", t("скрин 1.png"))]);
expect(parseInline("!скрин 1.png!", A)).toEqual([att("31337", t("скрин 1.png"))]);
});
it("два вложения с одним именем — первое в порядке хаба, даже если created null", () => {
const o = { attachments: [{ id: "5", filename: "скрин.png", created: null }, { id: "9", filename: "скрин.png", created: "2026-10-20T10:00:00.000+0300" }] };
expect(parseInline("!скрин.png!", o)).toEqual([att("5", t("скрин.png"))]);
});
it("[^имя] и [текст|^имя]", () => {
expect(parseInline("[^a.pdf]", A)).toEqual([att("31338", t("a.pdf"))]);
expect(parseInline("[отчёт|^a.pdf]", A)).toEqual([att("31338", t("отчёт"))]);
});
it("имя точно (с регистром); несколько с одним именем — самое новое", () => {
it("имя точно (с регистром); несколько с одним именем — первое в порядке хаба", () => {
expect(parseInline("!Скрин 1.png!", A)).toEqual([t("!Скрин 1.png!")]);
expect(parseInline("!дубль.png!", A)).toEqual([att("20", t("дубль.png"))]);
expect(parseInline("!дубль.png!", A)).toEqual([att("10", t("дубль.png"))]); // первое в порядке хаба
});
it("имени нет среди вложений — исходник как есть", () => {
expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]);

View File

@ -32,16 +32,9 @@ function safeHref(raw: string): string | null {
return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null;
}
// вложение по имени: несколько с одним именем — самое новое по created, затем по числовому id
// вложение по имени: несколько с одним именем — первое в порядке хаба
function findAttachment(opts: WikiOptions, name: string): string | null {
const same = (opts.attachments ?? []).filter((a) => a.filename === name);
if (!same.length) return null;
const rank = (a: WikiAttachment): [number, number] => [a.created ? Date.parse(a.created) || 0 : 0, Number(a.id) || 0];
return same.reduce((best, a) => {
const [bt, bi] = rank(best);
const [at, ai] = rank(a);
return at > bt || (at === bt && ai > bi) ? a : best;
}).id;
return (opts.attachments ?? []).find((a) => a.filename === name)?.id ?? null;
}
function keyPattern(projectKey: string | undefined): RegExp | null {