35 KiB
Пакет веб-багов (web-bugfixes) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: убрать видимые пользователю ошибки веба хаба из пакета A сверки (sdlc-hub#124), каждую закрепив тестом, который до правки падает.
Architecture: только клиент (web/src/): точечные правки окна карточки, разборщика вики-разметки, трекера
состояния модулей, вложений, доски и UI-кита. Одна новая чистая функция wikiPlain для текста ленты. API, схема, MCP и
агенты не меняются.
Tech Stack: React 19 + TypeScript + Vite, React Router (data router), TanStack Query, Vitest + Testing Library, Playwright.
Spec: openspec/changes/web-bugfixes/ — design.md (решения 1–21, ссылки на строки main 1405ccd),
specs/web-app/spec.md (дельта требований и сценарии), proposal.md.
Global Constraints
- Меняется только
web/src/(+web/e2e/issues.spec.ts). Сервер, API, MCP, агенты — без изменений. - Язык комментариев и сообщений коммитов — русский; идентификаторы — английские. Плотность комментариев — как в окружающем коде.
- Разметка MUST NOT превращаться в HTML строкой (
innerHTML,dangerouslySetInnerHTML). - Тесты — рядом с кодом (
*.test.ts(x)), запросы к хабу мокаютсяmockFetch/jsonизweb/src/test/fetchMock.ts. - Каждая задача начинается с теста, который падает до правки (RED подтверждается запуском и фиксируется в отчёте).
- Тексты интерфейса — дословно из спеки: «Вид работы в Jira», «Назад», «Закрыть», «Остаться», «Уйти без сохранения заметки?», «Повторить», «Загрузка…», «хаб не отвечает», «нет в зеркале», «Задачи {key} нет в зеркале хаба.».
- Коммит в конце каждой задачи; сообщение заканчивается строкой
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. - Зелёным считается только весь набор: финально
make checkиз корня репозитория иcd web && npm run e2e.
Review Focus
- Ctrl/Cmd/средний щелчок по ключу в списке «Задачи» по-прежнему открывает карточку в новой вкладке (перехват только обычного щелчка) — тест в Task 4.
- Окно, открытое прямым адресом (
depth = 0), и двойной ✕: адрес — список, дальше по истории не уходит — тест в Task 4. wikiPlainна незакрытой разметке, неизвестных макросах и пустом тексте не бросает и отдаёт текст как есть; пустой комментарий в ленте — по-прежнему «комментарий: —» — тест в Task 2.- Настройки ещё не пришли или без
kind_labels: все метки — в «Метках», строки «Вид работы в Jira» нет — тест в Task 3. - Смена проекта A → B → A во время опроса: пакет A снова подхватывается при возврате (сброс не «запирает» модуль) — тест в Task 5.
Task 1: Вики-разметка — краевые случаи (#56 M-5, M-6)
Files:
- Modify:
web/src/modules/jira/issues/wiki/inline.ts(ESCAPABLE:21,bracket:148-152) - Modify:
web/src/modules/jira/issues/wiki/parse.ts(MACRO:17,closeMacro:103-116,row:118-139) - Test:
web/src/modules/jira/issues/wiki/inline.test.ts,web/src/modules/jira/issues/wiki/parse.test.ts
Interfaces:
-
Consumes: —
-
Produces: прежние
parseWiki(text, opts): Block[],parseInline(line, opts, label?): Inline[]с исправленным разбором. -
Step 1: Write the failing tests
В inline.test.ts:
it("\\# — символ решётки (M-5)", () => {
expect(parseInline("\\# не пункт")).toEqual([{ t: "text", text: "# не пункт" }]);
});
it("[~логин|текст] — упоминание логина без подписи (M-6)", () => {
expect(parseInline("[~ivanov|Иванов]")).toEqual([{ t: "mention", login: "ivanov" }]);
});
В parse.test.ts:
it("\\# в начале строки — абзац, не список", () => {
expect(parseWiki("\\# не пункт")).toEqual([{ t: "p", children: [{ t: "text", text: "# не пункт" }] }]);
});
it("quote и noformat с параметрами (M-5)", () => {
expect(parseWiki("{quote:title=Итог}\nсогласовано\n{quote}")).toEqual([{ t: "quote", children: [{ t: "p", children: [{ t: "text", text: "согласовано" }] }] }]);
expect(parseWiki("{noformat:borderStyle=solid}\n*x*\n{noformat}")).toEqual([{ t: "pre", text: "*x*" }]);
});
it("пробелы после открывающего {code} не дают пустой строки (M-6)", () => {
expect(parseWiki("{code} \nx = 1\n{code}")).toEqual([{ t: "pre", text: "x = 1" }]);
});
it("непарная [ в ячейке не съедает разделители (M-6)", () => {
const [table] = parseWiki("|[draft|итог|готово|");
expect(table).toMatchObject({ t: "table" });
expect((table as { rows: { children: { text: string }[] }[][] }).rows[0].map((c) => c.children[0].text)).toEqual(["[draft", "итог", "готово"]);
});
it("| внутри [текст|адрес] ячейку по-прежнему не делит", () => {
const [table] = parseWiki("|[док|https://x.ru]|b|");
expect((table as { rows: unknown[][] }).rows[0]).toHaveLength(2);
});
- Step 2: Run tests to verify they fail
Run: cd web && npx vitest run src/modules/jira/issues/wiki/
Expected: FAIL — новые тесты (кроме последнего, он фиксирует текущее поведение и должен быть зелёным).
-
Step 3: Implement
-
ESCAPABLE— добавить#. -
MACRO = /^\s*\{(code|noformat|quote)(?::[^}]*)?\}/; имя тега вcloseMacro— первая группа (без параметров), закрывающий тег —{имя}. -
closeMacro: остаток строки открывающего тега, состоящий из одних пробелов, вbodyне попадает (text.trim()вместоtextв условии дляk === start). -
bracket: приbody.startsWith("~")логин —body.slice(1).split("|")[0].trim(); пустой или с пробелом —null, как сейчас. -
row:[увеличиваетdepth, только если вsправее позиции есть](s.indexOf("]", j + 1) >= 0). -
Step 4: Run tests to verify they pass
Run: cd web && npx vitest run src/modules/jira/issues/wiki/
Expected: PASS (все тесты каталога, включая прежние).
- Step 5: Commit
git add web/src/modules/jira/issues/wiki/
git commit -m "Вики-разметка: \\#, макросы с параметрами, [~логин|текст], пробелы после {code}, непарная [ в таблице (#124, #56 M-5, M-6)"
Task 2: Текст комментария в ленте без разметки (#103 С6)
Files:
- Create:
web/src/modules/jira/issues/wiki/plain.ts - Modify:
web/src/modules/jira/feed/events.ts(eventText, веткаcomment:72-75) - Test:
web/src/modules/jira/issues/wiki/plain.test.ts(новый),web/src/modules/jira/feed/events.test.ts
Interfaces:
-
Consumes:
parseWiki,Block,Inline,WikiOptionsиз Task 1 (parse.ts,inline.ts). -
Produces:
export function wikiPlain(text: string, opts?: WikiOptions): string— текст как читается отрендеренным, блоки через пробел (не одна строка — «одна строка» и обрезку делаетeventText). -
Step 1: Write the failing tests
plain.test.ts:
import { wikiPlain } from "./plain.ts";
it("выделение, ссылки, упоминание, заголовок", () => {
expect(wikiPlain("h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]").replace(/\s+/g, " ").trim())
.toBe("Итог Готово, см. протокол и @ivanov");
});
it("[адрес] — сам адрес, {{моно}} и {code} — содержимое, ---- — пусто", () => {
expect(wikiPlain("[https://x.ru] {{a_b}}\n----\n{code}\nx = 1\n{code}").replace(/\s+/g, " ").trim()).toBe("https://x.ru a_b x = 1");
});
it("ключ задачи и вложение — текстом", () => {
const opts = { projectKey: "AURORA", attachments: [{ id: "1", filename: "скрин.png", created: null }] };
expect(wikiPlain("см. AURORA-1 и !скрин.png!", opts).trim()).toBe("см. AURORA-1 и скрин.png");
});
it("незакрытая и неизвестная разметка — как есть, пустой текст — пусто (Review Focus 3)", () => {
expect(wikiPlain("{color:red}важно *без конца").trim()).toBe("{color:red}важно *без конца");
expect(wikiPlain("")).toBe("");
});
В events.test.ts:
it("комментарий в ленте — без вики-разметки (С6)", () => {
expect(eventText({ ...commentEvent, to: "h3. Итог\n*Готово*, см. [протокол|https://x.ru/p] и [~ivanov]" }))
.toBe("комментарий: Итог Готово, см. протокол и @ivanov");
});
it("пустой комментарий — по-прежнему «—»", () => {
expect(eventText({ ...commentEvent, to: " " })).toBe("комментарий: —");
});
(commentEvent — событие kind: "comment" по образцу уже имеющихся в events.test.ts.)
- Step 2: Run tests to verify they fail
Run: cd web && npx vitest run src/modules/jira/issues/wiki/plain.test.ts src/modules/jira/feed/events.test.ts
Expected: FAIL — нет модуля plain.ts; тест С6 видит сырую разметку.
- Step 3: Implement
wikiPlain обходит дерево parseWiki: text — текст; метки выделения — дети; code — текст; link — дети
(у [адрес] дети уже содержат адрес); issue — key, если детей нет, иначе дети; attachment — дети; mention —
"@" + login; br — пробел; блоки (heading, p, list с пунктами и вложенными списками, table по ячейкам,
pre — text, quote — вложенные блоки, hr — пусто) соединяются пробелом. В eventText ветка comment:
oneLine(wikiPlain(e.to ?? "")), остальное (обрезка 300 и «…», «—» для пустого) — как сейчас.
- Step 4: Run tests to verify they pass
Run: cd web && npx vitest run src/modules/jira/issues/wiki/ src/modules/jira/feed/
Expected: PASS.
- Step 5: Commit
git add web/src/modules/jira/issues/wiki/plain.ts web/src/modules/jira/issues/wiki/plain.test.ts web/src/modules/jira/feed/
git commit -m "Лента: текст комментария без вики-разметки Jira (#124, #103 С6)"
Task 3: Карточка — связи «нет в зеркале», окно 404, метки видов, пустое описание (#42 M-5, #103, #56 M-4)
Files:
- Modify:
web/src/modules/jira/issues/card.ts(новая функция) - Modify:
web/src/modules/jira/issues/IssueCard.tsx(404/ошибка :34-49,Links:110-134, описание :79,OtherFields:225-237) - Modify:
web/src/modules/jira/issues/overlay.tsx(передать в карточкуonBack, еслиcard.depth >= 1… см. Step 3) - Test:
web/src/modules/jira/issues/card.test.ts(если нет — создать),web/src/modules/jira/issues/IssueCard.test.tsx,web/src/modules/jira/issues/IssueModal.test.tsx
Interfaces:
-
Consumes:
JIRA_URL(../board/constants.ts),settings.data.kind_labels(вид → метка). -
Produces:
export function splitKindLabels(labels: string[], kindLabels: Record<string, string> | undefined): { kinds: string[]; rest: string[] }вcard.ts—kinds— названия видов в порядке меток задачи,rest— прочие метки;IssueCardполучает необязательный пропonBack?: () => void;IssueModalпрокидывает его изuseIssueOverlay.
-
Step 1: Write the failing tests
card.test.ts:
it("метки видов работ отдельно от прочих", () => {
expect(splitKindLabels(["AWPCA", "kind-dev", "kind-ui"], { разработка: "kind-dev", UI: "kind-ui" }))
.toEqual({ kinds: ["разработка", "UI"], rest: ["AWPCA"] });
});
it("без kind_labels — все метки прочие (Review Focus 4)", () => {
expect(splitKindLabels(["AWPCA", "kind-dev"], undefined)).toEqual({ kinds: [], rest: ["AWPCA", "kind-dev"] });
});
IssueCard.test.tsx:
- «Прочие поля»: задача с метками
["AWPCA", "kind-dev"], настройки фикстурыsettings.json→ видны пары «Вид работы в Jira» / «разработка» и «Метки» / «AWPCA», текстаkind-devв карточке нет. - описание
" \n\n"→ нет заголовка «Описание». - связь на ключ вне зеркала (
summary: null, status: null) → в «Связях»getByRole("link", { name: /AURORA-1999/ })имеетhref=${JIRA_URL}/browse/AURORA-1999иtarget="_blank"; рядом текст «нет в зеркале».
IssueModal.test.tsx (сценарий спеки «Задача не в зеркале после перехода»): из списка открыта AURORA-9002, в окне
переход по ссылке на ключ, для которого хаб отвечает 404 (json(404, {}) в hub() для этого ключа) → в окне «Задачи
AURORA-1999 нет в зеркале хаба.» и кнопки «Назад» и «Закрыть»; «Назад» — заголовок окна снова AURORA-9002; при новом
переходе и «Закрыть» — окна нет, адрес — список. Прямой адрес /app/jira/issues/AURORA-1999 (404) → только «Закрыть».
- Step 2: Run tests to verify they fail
Run: cd web && npx vitest run src/modules/jira/issues/card.test.ts src/modules/jira/issues/IssueCard.test.tsx src/modules/jira/issues/IssueModal.test.tsx
Expected: FAIL — нет splitKindLabels, метка kind-dev видна в «Метках», рамка описания есть, ссылка ведёт на /jira/issues/…, кнопки «Назад» нет.
-
Step 3: Implement
-
OtherFields: строки «Вид работы в Jira» (kinds.join(", ")) и «Метки» (rest.join(", ")) вместо прежней «Метки»;kind_labels— изuseSettings()(передать пропом изIssueCard). Положение «Вид работы в Jira» — сразу перед «Метками». -
Описание: условие
i.description?.trim(). -
Links: приl.summary === null && l.status === nullключ —<a href={${JIRA_URL}/browse/${l.key}} target="_blank" rel="noopener">со значкомIconExternal(как «Открыть в Jira»), иначе —IssueLink, как сейчас. -
useIssueOverlay: «Назад» — только если окно открыто переходом из другой карточки. ПоopenCardState(modal.ts:25-28): из спискаdepth = 1, direct = false; прямой адрес —depth = 0, direct = true; каждый переход внутри окна —depth + 1. ОтсюдаonBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1)) ? () => void navigate(-1) : undefined; прокинуть черезIssueModalвIssueCard. Тесты: «из списка — без Назад», «после перехода — с Назад», «прямой адрес — без Назад», «переход из прямого адреса — с Назад». -
404 и ошибка загрузки:
action— «Назад» (еслиonBack) и «Закрыть» / «Повторить», «Назад» (если есть), «Закрыть». -
Step 4: Run tests to verify they pass
Run: cd web && npx vitest run src/modules/jira/issues/
Expected: PASS.
- Step 5: Commit
git add web/src/modules/jira/issues/
git commit -m "Карточка: связь вне зеркала ведёт в Jira, «Назад» у окна 404, метки видов работ отдельно, пустое описание (#124, #42 M-5, #103, #56 M-4)"
Task 4: Окно карточки — поиск, двойное закрытие, подтверждение ухода (#42 M-1, M-2, M-4)
Files:
- Modify:
web/src/modules/jira/issues/IssuesPage.tsx(:14-38,open:50) - Modify:
web/src/modules/jira/issues/IssueTable.tsx(ключ :38 — обычный щелчок черезonOpen) - Modify:
web/src/modules/jira/issues/overlay.tsx(close:37-41) - Modify:
web/src/modules/jira/issues/NoteForm.tsx(подтверждение ухода :82-89, эффект :42) - Test:
web/src/modules/jira/issues/IssueModal.test.tsx,web/src/modules/jira/issues/IssuesPage.test.tsx,web/e2e/issues.spec.ts
Interfaces:
-
Consumes:
openCardState,cardPath(modal.ts,IssueLink.tsx). -
Produces:
IssueTableсохраняет пропonOpen(key: string): void— теперь его зовут и строка, и обычный щелчок по ключу. -
Step 1: Write the failing tests
IssueModal.test.tsx:
- «поиск перед открытием не закрывает окно» (сценарий спеки): список, в поле поиска (
getByRole("searchbox")) набрано «свод», сразу щёлкнут ключ AURORA-9002 (без ожидания 300 мс) → окно открыто; послеawait new Promise(r => setTimeout(r, 400))окно всё ещё открыто (screen.getByRole("dialog")),router.state.location.pathname—/app/jira/issues/AURORA-9002; после ✕ адрес —/app/jira/issuesсq=свод. То же для щелчка по ячейке темы строки. - «двойной щелчок по ✕ не уводит дальше назад»: история
["/app/jira/links", "/app/jira/issues"], открыта AURORA-9002, переход по связи на AURORA-9001,await userEvent.dblClick(✕)→ адрес —/app/jira/issues, не/app/jira/links. - то же для прямого адреса (Review Focus 2): история
["/app/jira/issues/AURORA-9002"], двойной ✕ →/app/jira/issues. - подтверждение ухода: в форме заметки правка, нажата Esc →
getByRole("alertdialog", { name: "Уйти без сохранения заметки?" }),document.activeElement— кнопка «Остаться».
IssuesPage.test.tsx (Review Focus 1): у ключа строки href прежний (/jira/issues/AURORA-9001), щелчок с ctrlKey
(fireEvent.click(link, { ctrlKey: true })) не вызывает навигацию внутри приложения (адрес страницы не меняется).
web/e2e/issues.spec.ts: сценарий «поиск перед открытием карточки» — ввести текст в поиск и сразу щёлкнуть первую
строку; через 600 мс окно открыто; Escape — адрес списка содержит q=.
- Step 2: Run tests to verify they fail
Run: cd web && npx vitest run src/modules/jira/issues/IssueModal.test.tsx src/modules/jira/issues/IssuesPage.test.tsx
Expected: FAIL — окно закрывается через 300 мс; двойной ✕ уходит на /app/jira/links; у подтверждения нет имени и фокуса.
-
Step 3: Implement
-
IssuesPage: функцияopenCard(key)— снять таймер поиска; еслиqотличается отfilters.q, записатьqв адрес (replace) и открыть карточку с фоном, у которогоsearchуже содержитq(navigate(cardPath(key), { state: openCardState({ ...loc, search: <поиск с q> }) }),written.current = q). Её получаетIssueTableкакonOpen. Таймер не перезаводится, пока страница под окном (адресfilters.qсовпадает сqпосле записи — эффект ничего не делает). -
IssueTable: ключ — та же ссылкаIssueLink(адрес и состояние для новой вкладки), но обычный щелчок (левая кнопка безctrl/meta/shift/alt) —preventDefault()иonOpen(key). -
overlay.tsx:closingKey = useRef<string | null>(null);closeвыходит, еслиclosingKey.current === location.key, иначе запоминаетlocation.keyи закрывает как сейчас. Сброс — эффект по сменеlocation.key. -
NoteForm: у блока подтверждения уходаaria-label="Уйти без сохранения заметки?"; ref на кнопку «Остаться»; приblocker.state === "blocked"—scrollIntoView(как сейчас) иfocus()этой кнопки. -
Step 4: Run tests to verify they pass
Run: cd web && npx vitest run src/modules/jira/issues/ && npm run e2e -- issues.spec.ts
Expected: PASS.
- Step 5: Commit
git add web/src/modules/jira/issues/ web/e2e/issues.spec.ts
git commit -m "Окно карточки: поиск не закрывает окно, повторное закрытие игнорируется, фокус подтверждения ухода (#124, #42 M-1, M-2, M-4)"
Task 5: Строка состояния — ответ опроса прежнего проекта (#116)
Files:
- Modify:
web/src/shell/activity/ActivityProvider.tsx(эффект опроса :126-154) - Test:
web/src/shell/activity/activity.test.tsx
Interfaces:
-
Consumes: —
-
Produces: поведение
ModuleActivityбез изменений интерфейса. -
Step 1: Write the failing test
В activity.test.tsx — детерминированный тест «ответ опроса после смены проекта не возвращает событие»: ответ
GET /batches/9 (пакет wiki_sync проекта AWPCA) задерживается промисом-заглушкой (deferred из web/src/test/batchHub.ts);
когда первый ответ показан (event.kind === "batch"), следующий ответ держится; в одном act вызвать
m.setProject("STOCKS_DRRK") и разрешить задержанный ответ → после await waitFor(...) и sleep(50) m.now().wiki.event
равно null. Исполнитель подбирает порядок так, чтобы тест падал на текущем коде (RED обязателен, см. Risks в design.md);
если воспроизвести в одном такте не удаётся — описать в отчёте, как получен RED (например, pollMs 0 и разрешение
ответа синхронно перед setProject).
Второй тест (Review Focus 5): A → B → A — после возврата на AWPCA незавершённый пакет 9 снова подхватывается
(event { kind: "batch", id: 9 }).
- Step 2: Run tests to verify they fail
Run: cd web && npx vitest run src/shell/activity/activity.test.tsx
Expected: FAIL — первый тест: событие batch id 9 после смены проекта.
- Step 3: Implement
В начале эффекта опроса: if (!active || activeRef.current !== active) return; — ответ обрабатывается, только если
отслеживаемый пакет тот же объект, что в activeRef (смена проекта синхронно обнуляет activeRef в setActiveNow).
Проверить, что setActiveNow({ ...active, own: true }) (:138) и track/start по-прежнему проходят: они сами
обновляют activeRef.
- Step 4: Run tests to verify they pass
Run: cd web && npx vitest run src/shell/activity/activity.test.tsx и прогон в цикле:
for i in $(seq 20); do npx vitest run src/shell/activity/activity.test.tsx --silent || break; done
Expected: PASS все 20 прогонов.
- Step 5: Commit
git add web/src/shell/activity/
git commit -m "Состояние модулей: ответ опроса прежнего проекта не возвращает событие (#124, #116)"
Task 6: Список задач ждёт настройки (#60 M-3)
Files:
- Modify:
web/src/modules/jira/issues/IssuesPage.tsx(запрос :45-48, «Загрузка…» :60-61) - Modify:
web/src/modules/jira/api/queries.ts(useIssues— необязательный параметрenabled) - Test:
web/src/modules/jira/issues/IssuesPage.test.tsx
Interfaces:
-
Consumes:
useSettings()(isPending). -
Produces:
useIssues(filters: IssueFilters, opts?: { enabled?: boolean }). -
Step 1: Write the failing test
«закрытый статус в адресе до настроек» (сценарий спеки): ответ /settings задержан; маршрут /issues?status=Готово;
пока настройки не пришли — issuesCalls(fetch) пуст, видно «Загрузка…», нет «По этим фильтрам задач нет»; после
ответа /settings (с «Готово» в closed_statuses) — ровно один вызов /issues с status=Готово и без hide_closed.
Второй тест (R-11): /settings отвечает 500 → один вызов /issues с hide_closed=1.
- Step 2: Run test to verify it fails
Run: cd web && npx vitest run src/modules/jira/issues/IssuesPage.test.tsx
Expected: FAIL — первый вызов /issues уходит до настроек с hide_closed=1.
- Step 3: Implement
useIssues(query, { enabled: !settings.isPending }); пока настройки грузятся, «Загрузка…» (условие подсказки — по
list.isPending || settings.isPending), сообщение «По этим фильтрам задач нет» — только после ответа списка.
- Step 4: Run tests to verify they pass
Run: cd web && npx vitest run src/modules/jira/issues/
Expected: PASS.
- Step 5: Commit
git add web/src/modules/jira/
git commit -m "Список задач: запрос после настроек, без мелькания «задач нет» (#124, #60 M-3)"
Task 7: Вложения — прежняя ошибка, 401 при опросе, порядок хаба (#86 T6.2, T6.3, M-4)
Files:
- Modify:
web/src/modules/jira/issues/attachments/useAttachmentDownload.ts - Modify:
web/src/modules/jira/issues/attachments/AttachmentList.tsx(newestFirst:14-17, ошибка :36) - Modify:
web/src/modules/jira/issues/wiki/inline.ts(findAttachment:36-45) - Test:
web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx,web/src/modules/jira/issues/IssueCard.test.tsx,web/src/modules/jira/issues/wiki/inline.test.ts
Interfaces:
-
Consumes:
dropTokenOn,AuthError,HubDownError(web/src/api/client.ts). -
Produces:
DownloadStatusдополняется полемok: boolean(успех в этой карточке);IDLE.ok = false. -
Step 1: Write the failing tests
-
useAttachmentDownload.test.tsx: опросGET /attachments/{id}отвечает 401 → вызван сброс токена (токен в хранилище пуст / экран входа по образцу других тестовAuthError), текста ошибки нет; ответ — сеть недоступна (HubDownError) →status(id).error === "хаб не отвечает". -
IssueCard.test.tsx(сценарий «Повтор после ошибки»): вложение сstate: "failed", error: "сбой", «Повторить», хаб отдаётreadyи файл, перечитанная карточка всё ещё отдаётfailed(задержать) → текста «сбой» нет. -
IssueCard.test.tsx: вложения в ответе хаба в порядке[b (created null), a (created 2026-10-20)]→ в разделе в том же порядке. -
inline.test.ts: два вложения «скрин.png» —[{id: "5", created: null}, {id: "9", created: "2026-10-20T10:00:00.000+0300"}]→!скрин.png!даётattachmentсid: "5"(первое в порядке хаба). -
Step 2: Run tests to verify they fail
Run: cd web && npx vitest run src/modules/jira/issues/attachments/ src/modules/jira/issues/IssueCard.test.tsx src/modules/jira/issues/wiki/inline.test.ts
Expected: FAIL.
-
Step 3: Implement
-
В
useAttachmentDownload: общая функция разбора ошибки —AuthError→dropTokenOn(e)без текста;HubDownError→ «хаб не отвечает»; прочие —errorText. Успех —put(id, { busy: false, error: null, ok: true });startсбрасываетok. -
AttachmentList: порядок — как вitems; серверныйfailedпоказывается, только если!ok. УбратьnewestFirst(и его экспорт, если других потребителей нет). -
findAttachment: первое вложение с этим именем в порядкеopts.attachments. -
Step 4: Run tests to verify they pass
Run: cd web && npx vitest run src/modules/jira/issues/
Expected: PASS.
- Step 5: Commit
git add web/src/modules/jira/issues/
git commit -m "Вложения: без прежней ошибки после повтора, 401 — экран входа, порядок хаба (#124, #86 T6.2, T6.3, M-4)"
Task 8: Доска, проекты, поиск UI-кита (#35 T4.1/M-15, #64 T9.1, M-6, #68 T3.3) и итоговая проверка
Files:
- Modify:
web/src/modules/jira/board/useBoardPrefs.ts(toggle,toggleHidden:39-54) - Modify:
web/src/modules/jira/board/model/fromHub.ts(hasSprint:30) - Modify:
web/src/projects/ProjectGate.tsx(:11) - Modify:
web/src/ui/inputs.tsx(SearchInput:15-24) - Test:
web/src/modules/jira/board/BoardPage.test.tsxили новыйuseBoardPrefs.test.tsx, тест модели доски рядом сfromHub.ts,web/src/projects/projects.test.tsx,web/src/ui/forms.test.tsx
Interfaces:
-
Consumes:
useProjects()(refetch) изweb/src/api/queries.ts. -
Produces: —
-
Step 1: Write the failing tests
-
useBoardPrefs:renderHook; в одномactдва вызоваtoggle(<флаг A>)иtoggle(<флаг B>)→ оба флага включены и в состоянии, и вlocalStorage; в одномacttoggleHidden("kind", "x")иtoggleHidden("kind", "y")→ скрыты оба. -
fromHub: снимок, где спринт только у эпика (тип «Эпик»,neighbor: false), у остальных своих задач спринта нет →hasSprint === false. -
projects.test.tsx:GET /projects→ 500, затем 200 → видно «Проекты не загрузились: …» и кнопка «Повторить»; после нажатия — содержимое страницы проекта. -
forms.test.tsx:SearchInputсо значением, щелчок «Очистить» →onChange("")и фокус в поле поиска (document.activeElement === getByRole("searchbox")) — с обёрткой-состоянием, чтобы кнопка исчезла. -
Step 2: Run tests to verify they fail
Run: cd web && npx vitest run src/modules/jira/board/ src/projects/ src/ui/
Expected: FAIL.
-
Step 3: Implement
-
useBoardPrefs:prefsRefс последним значением;toggle/toggleHiddenсчитают новое значение изprefsRef.current, синхронно присваиваютprefsRef.current = next, пишутst.writeFlag/st.writeHiddenиsetPrefs(next). -
fromHub:hasSprint = snap.issues.some((it) => !isEpic(it) && !it.neighbor && !!it.sprint). -
ProjectGate:action={<Button onClick={retry}>Повторить</Button>}, гдеretryперезапрашиваетGET /projects. -
SearchInput:refнаinput; «Очистить» —onChange(""), затемref.current?.focus(). -
Step 4: Run the whole suite
Run (из корня репозитория): make check, затем cd web && npm run e2e
Expected: всё зелёное (pytest, lint, vitest, e2e).
- Step 5: Commit
git add web/src/
git commit -m "Доска, проекты, поиск: два переключения не теряются, спринт эпика, «Повторить», фокус после «Очистить» (#124, #35 T4.1, #64 T9.1, M-6, #68 T3.3)"