docs: delete test strategy report

Remove the large `test-strategy-report.md` file which is outdated and has been superseded by newer testing documentation, reducing repository clutter.
This commit is contained in:
vvzvlad
2026-06-17 01:35:16 +03:00
parent 1750058503
commit b630a5ccac

View File

@@ -1,248 +0,0 @@
# Отчёт по тест-стратегии — docmost-sync — 2026-06-16
> Двунаправленная синхронизация статей Docmost с локальным Markdown-git-хранилищем
> (git — хранилище состояния). Монорепо: корневое приложение-движок (`src/`) +
> библиотека `packages/docmost-client` (~7.5k LOC). Стек: TypeScript ESM, Node ≥ 20,
> Vitest 3.2.6. Все тесты лежат в корневом `test/` (`include: ['test/**/*.test.ts']`).
## 1. Исполнительное резюме
- **Проанализировано модулей:** 9 (1 субагент `module-testability-analyst` на модуль, все завершились).
- **Предложено тестов (unit / integration / contract / E2E):** **50 / 7 / 1 / 2** (итого 60).
- unit = 83 % (≥ 70 % ✓), integration = 12 % (≤ 20 % ✓), E2E = 3 % и 2 шт. (≤ 5 % и ≤ 10 ✓).
- **Отклонено как малоценные:** ≈ 60 символов/областей (декларативные spec-объекты схемы,
тривиальные плоские мапперы, framework-обвязка, type-only интерфейсы, passthrough-обёртки).
- **Покрытие сейчас (проверено v8 лично):** **2.6 %** statements по обоим пакетам
(искажено огромным непокрытым `docmost-client`). Изолированно: корневое приложение ≈ **40 %**,
пакет `docmost-client`**0 %** (поведенчески покрыт лишь `collectRecentSince`).
**Прогноз после Фаз 1–4:****60–65 %** (чистые lib-модули 80 %+, корневое приложение ≈ 85 %,
транспортный `client.ts` ≈ 40 %).
> ⚠️ **Артефакт измерения покрытия.** `package.json` пакета указывает `main: dist/index.js`,
> поэтому `import from 'docmost-client'` грузит **скомпилированный `dist/`**, а не `src/`.
> v8 меряет `src/` → показывает `client.ts` 0 %, хотя `collectRecentSince` реально исполняется.
> **Перед измерением покрытия** добавить в `vitest.config.ts` alias `docmost-client → packages/docmost-client/src/index.ts`
> (или мерить по `dist` после сборки), иначе любые новые тесты библиотеки не отразятся в отчёте.
> `@vitest/coverage-v8` и скрипт `"coverage"` в проекте отсутствуют — их нужно добавить.
## 2. Рекомендации по модулям
### app-root (`src/`) — движок синка, конфиг, sanitize, round-trip-харнесс
- **Извлечь в чистые функции:** `folderSegmentsFor` (`pull.ts:88`, замкнута внутри `main`),
`firstDivergence`/`parseArgs` (`roundtrip.ts:101/64`, не экспортированы).
- **Unit добавить:** `firstDivergence` (равные/разные деревья, путь расхождения, циклы) —
ловит ложное «stable» при реальном расхождении (вся суть харнесса); `nameForNode` (коллизии
имён сиблингов → перезапись файлов на диске); `folderSegmentsFor` (вложенность + защита от цикла
parent A→B→A, иначе зависание); `parseArgs`; ветка invalid-value в `loadSettingsOrExit`
(`config-errors.ts:27-30`, единственный значимый пробел).
- **Integration добавить:** `pull.main` с фейковым клиентом + временной директорией
(один файл на страницу, верные папки, узлы без id пропускаются) — после R-App-4.
- **НЕ тестировать:** `index.ts` (тонкий CLI-passthrough, только `console.log`); `envSchema`
(тестировать = тестировать Zod, покрыт через `parseSettings`); тело `roundtrip.main`
(байт-стабильность уже покрыта `roundtrip.test.ts`); `invokedDirectly`-guard-блоки;
`sanitizeTitle`/`disambiguate`/`parseSettings`/`stripBlockIds` (уже ~100 %).
### client-core (`packages/docmost-client/src/client.ts`, ~2770 строк) — god-object REST+WS клиент
- **Извлечь в чистые функции:** валидаторы `isSafeUrl`/`validateDocUrls`/`validateDocStructure`
(`client.ts:905/941/1004`), `imageMimeFromPath`/`buildImageNode` (1844/1864) — поднять в `lib/`
рядом с `filters.ts`; распаковку конвертов и clamp-логику пагинации (378-393, 1505) в pure-функции.
- **Unit добавить:** XSS-allowlist `isSafeUrl`+`validateDocUrls` (`javascript:`/`data:`/`file:`,
пробельно-контрольный обход `java\tscript:`, на всех медиа-узлах) — **высший приоритет по безопасности**;
`validateDocStructure` (глубина > 200, не-string type); расширить `collectRecentSince`
(граница `updatedAt === sinceIso`, элементы без `id`/`updatedAt`); `imageMimeFromPath`+`buildImageNode`;
`paginateAll` (стоп-условия, MAX_PAGES=50 + предупреждение, clamp 1..100, оба конверта) — после R-Client-2;
`appUrl`/`shareUrl`/`parseCommentContent`; sandbox `transformPage` (`node:vm`: нет `require`/`process`/`fs`,
таймаут 5 c, не-функция/не-doc → throw) — security.
- **Integration добавить (после R-Client-1, инъекция HTTP):** авто-реавторизация
(401-интерсептор + дедуп `login` + `getCollabTokenWithReauth`: один retry, `/auth/login` не ретраится,
`loginPromise` сбрасывается в `finally`); `uploadImage` (порядок guard ext→stat→read, > 20 MiB,
пересборка FormData на 401, нет утечки тела ответа в ошибку); `createPage` (replay multipart на 401);
`checkNewComments` (битая дата → throw, а не «ничего нового»; граница `createdAt > since`; флаг truncated).
- **НЕ тестировать:** тонкие REST-passthrough (`getWorkspace`/`getSpaces`/`renamePage`/`movePage`/
`deletePage`/`restorePage`/`listTrash` и пр.) — конверт `data.data ?? data` покрыть один раз
извлечённой функцией; делегаты в node-ops/converter/diff (тестировать в их модулях); сами axios/yjs/hocuspocus.
### markdown-conversion (`lib/markdown-converter.ts` + `markdown-document.ts`) — конвертер ProseMirror↔Markdown
- **Unit добавить:** табличная golden-матрица по типам узлов (заголовки, маркированные/кодовые
спаны, ссылки с title, картинки с пробелами/скобками в src, кодоблоки с языком + срез хвостовых `\n`,
GFM-таблицы с выравниванием, spanned-таблицы → `<table>`, blockquote, task-list, math `a < b`,
mention/attachment/callout/details/columns/медиа, hr, hard break, неизвестный тип, пустой doc → `""`);
идемпотентность экранирования (`escapeAttr` стабилен на `& "`, `encodeMdUrl` пробел→`%20`),
отступы вложенных списков (`indentItemChildren`); envelope `parseDocmostMarkdown`/`serializeDocmostMarkdown`
(восстановление meta/body/comments, CRLF, «последний `docmost:comments`-блок побеждает», throw на битом JSON);
edge/malformed-вход (`null`/`{}`/нет content, отсутствующие attrs, глубокая вложенность без переполнения стека).
- **Integration добавить:** **property-тест round-trip идемпотентности**`md→PM→md == md` байт-в-байт
+ семантическая стабильность через `stripBlockIds`. **Самый ценный тест проекта** (фантомные git-диффы —
ровно то, ради чего существует харнесс). Требует фабрику документов и генератор (см. §3).
- **НЕ тестировать:** интерфейс `DocmostMdMeta`; одиночный токен `{{SUBPAGES}}`; внутренности
`marked`/`@tiptap/html`; underline/sub/sup как отдельные тесты — свернуть в один inline-marks-кейс.
### prosemirror-schema (`lib/docmost-schema.ts`, ~1065 строк) — ~90 % декларативный конфиг
- **Unit добавить (ровно 2, намеренно не раздуваем):** `sanitizeCssColor` (`:44`) — allowlist против
CSS/style-инъекции: принять named/hex3-8/rgb(a)/hsl(a), отвергнуть `red; --x:url()`, `expression(...)`,
`red"><script>`, пустое/не-string; `clampCalloutType` (`:21`) — нормализация enum + регистр + фолбэк `info`.
- **НЕ тестировать:** все `Node.create`/`Mark.create`/`Extension.create` spec-объекты (~26 шт.) и
триплеты `default`/`parseHTML`/`renderHTML` — декларативные данные, тест тавтологичен; поведение
узлов проверяется **косвенно** через round-trip (другой модуль). Closures `textStyle.getAttrs`,
`Highlight`-guard, `Column.width` — покрыть HTML-фикстурами round-trip, не лезть в приватные closures.
### node-ops (`lib/node-ops.ts`, ~897 строк) — чистые структурные операции над деревом узлов
- **Unit добавить (все unit, высочайший ROI — JSON-вход/JSON-выход):** `insertNodeRelative`
(append/before/after, by-id/by-anchor, маршрутизация структурных узлов, throw-ветки, offset);
`insertTableRow` (индекс/паддинг/наследование типа и colwidth заголовка, OOB→append); `replaceNodeById`
(изоляция клонов на N совпадений, без рекурсии в подставленный узел); `getNodeByRef` (`#n` in/out-of-range,
дубль id → первый, гарантия клона); `updateTableCell` (переиспользование id первого параграфа,
сохранение colspan/rowspan, OOB→throw); `deleteNodeById`/`deleteTableRow` (throw vs тихий no-op);
`sanitizeForYjs`+`findUnstorableAttr` (срез `undefined`, путь до bigint/function); `buildOutline`+`readTable`+
`blockPlainText` (cols из row-0, усечение, ragged-таблицы). **Везде** ассерт «вход не мутирован».
- **Извлечь/рефактор:** инъекция `makeFreshId` (`:591`, `Math.random()`) — для точных ассертов на id
в `insertTableRow`/`updateTableCell`; иначе проверять формат+уникальность без рефактора.
- **НЕ тестировать:** интерфейсы `OutlineEntry`/`InsertOptions`; внутренние `clone`/`isObject`/`matchesId`/
`truncate`/`makeCellParagraph`/`locateTable` (покрыты транзитивно); недостижимый fallback `structuredClone`.
### collaboration (`lib/collaboration.ts`, ~618 строк) — чистый верх + транспортный низ (Yjs/Hocuspocus/WS)
- **Unit добавить (чистый верх, без рефактора):** `buildCollabWsUrl` (http→ws, https→wss, срез `/api`,
`/collab` ровно один раз, drop query/hash, fallback на битый URL); `buildYDoc`/`assertYjsEncodable`
(валид кодируется; `undefined`-attr санитайзится; неэнкодируемый attr → ошибка с путём; dryRun==apply);
`bridgeTaskLists` (ol со всеми чекбоксами → ul taskList, без фантомного orderedList); `preprocessCallouts`
(`:::` внутри кодоблока не считается забором; незакрытый callout); `replacePageContent` (guard не-doc → throw).
- **Unit (после рефактора R-Collab-1):** ядро `onSynced` read-transform-write — пустой live-doc → дефолт,
`transform→null` без записи, `transform throw` пробрасывается, фрагмент заменяется полностью.
**Защищает от потери данных при конкурентном редактировании** (инвариант «без `await` между read и write»).
- **Unit (после R-Collab-2):** подавление ложного успеха — `unsyncedChanges→0` при разрыве не считается
успехом (флаг `connectionLost`); ловит «ложную персистенцию» / reconnect-шторм как успешную запись.
- **Integration:** `mutatePageContent` против mock-Hocuspocus-сервера (после R-Collab-2/3 + fake-таймеры).
- **НЕ тестировать:** `updatePageContentRealtime` (passthrough); глобальная мутация `window`/`document`/
`WebSocket` на импорте (env-обвязка); внутренности yjs/hocuspocus/marked.
### transforms (`lib/transforms.ts`, ~477 строк) — чистые примитивы трансформации документа
- **Unit добавить:** `commentsToFootnotes` — перенумерация/порядок (маркеры не по порядку массива →
`[1]..[k]` в порядке чтения, список заметок переупорядочен) **и** иммутабельность входа + throw на
несогласованности (`[9]` при 3 заметках, нет heading/orderedList); `insertMarkerAfter` (сплит по нескольким
text/mark-ранам, маркер plain, окружающие марки сохранены, scope `beforeBlock`); `setCalloutRange`
(статичность regex `lastIndex` на двух text-узлах, только внутри callout, Unicode `…` и ASCII `...`);
`mdToInlineNodes` (срез префиксов `комментарий:`/`N.`, граница `**bold**`-лида, пробел не теряется);
`walk`/`getList` (полнота обхода, live-ссылка не клон).
- **Извлечь/рефактор:** инъекция `freshId` (`:240`) — опционально, для воспроизводимого dryRun.
- **НЕ тестировать:** `blockText` (re-export `node-ops.blockPlainText`); `splitInlineBold` (внутр.);
`clone`/`isObject`/`freshId`; интерфейсы. Sandbox-eval `(doc,ctx)=>doc` живёт в `client.ts`, не здесь.
### diff (`lib/diff.ts`, ~319 строк) — headless-дифф документов (чистый, детерминированный)
- **Unit добавить:** `diffDocs` (вставка/удаление/идентичность + пустые doc; счётчики
`inserted`/`deleted`); подсчёт целостности (images/tables/callouts old→new, дедуп ссылки,
разбитой на два рана; битая ссылка считается 1 раз); `footnoteMarkers` (граница body/notes по
`notesHeading`, порядок чтения, кастомный/отсутствующий heading); coarse-fallback (форс-исключение
precise-пути → нет throw, есть пометка о деградации, whitespace-блоки не репортятся); `blockContextAt`+
`blocksChanged` (усечение >80, не-пустой контекст ловит проглоченный `catch`, дедуп блоков).
- **НЕ тестировать:** `getSchema(docmostExtensions)` (обвязка); сам алгоритм
`recreateTransform`/`ChangeSet`/`simplifyChanges`; интерфейсы; точный порядок строк секции Changes
в markdown (порядок задаёт библиотека — проверять множества/счётчики).
### client-utils (`lib/auth-utils.ts` + `filters.ts` + `json-edit.ts` + `page-lock.ts`, 345 строк)
- **Unit добавить:** `applyTextEdits` (`json-edit.ts:45`) — полный набор: single/`replaceAll`,
multi-match без replaceAll → throw, «not found» vs «spans multiple formatting runs», **литеральная
вставка `$&`/`$1`** (явный foot-gun String.replace), обрезка пустых узлов, иммутабельность входа;
`withPageLock` (`page-lock.ts:16`) — сериализация одной страницы, конкурентность разных, ошибка не
«отравляет» очередь, реальный reject доходит до вызывающего (через deferred-промисы, **не** sleep);
`performLogin` парсинг cookie (точное имя `authToken``authTokenRefresh`, base64-`=` не обрезается);
`filterPage` (условный spread: `content === ""` включается, не-string опускается); `getCollabToken`
(распаковка `data.data.token``data.token`, `err.status` выживает, тело ответа не утекает без `DEBUG`);
`filterComment` (`??` vs `||`: пустая строка markdownContent сохраняется).
- **НЕ тестировать:** `filterWorkspace`/`filterSpace`/`filterGroup` (плоские мапперы без ветвлений —
максимум один общий shape-ассерт); интерфейсы `TextEdit`/`TextEditResult`; приватные
`collectText`/`countOccurrences`/`truncate`.
## 3. Сквозные аспекты
- **Contract-тесты** (1 набор): между `docmost-client` и живым Docmost — записанные фикстуры/pact-стиль,
проверяющие конверты ответов (`data.data ?? data`, `items`-vs-bare-array, `meta.hasNextPage`), от которых
зависит весь клиент. Привязать к закреплённой версии Docmost; ловит дрейф контракта API.
- **Property-based** (через извлечение чистых функций): (1) round-trip Markdown — корона стратегии;
(2) инварианты иммутабельности node-ops; (3) идемпотентность `commentsToFootnotes`/`setCalloutRange`.
Рекомендуется dev-зависимость `fast-check` (с воспроизводимым seed + shrinking).
- **Дымовые/нагрузочные:** неприменимо (нет высоконагруженных путей); пропустить.
- **Test-data factories (нужны):** билдер ProseMirror-документов (узлы/марки) для golden+property-тестов;
фабрика конвертов REST-ответов Docmost; фабрика login/Set-Cookie-ответа; корпус фикстур для round-trip
(расширить `test/fixtures/sample-doc.json`).
## 4. Обнаруженные антипаттерны
- **God-объект:** `DocmostClient` — ~2770 строк, ~58 членов (auth + REST + WS + FS + comments + vm-sandbox)
в одном классе (`client.ts`). Нет шва для изоляции одной ответственности.
- **Скрытые побочные эффекты на импорте:** глобальная мутация `global.window`/`document`/`Element`/`WebSocket`
(`collaboration.ts:13-19`) — импорт модуля меняет глобал воркера; конструктор `DocmostClient` вешает
axios-интерсептор и создаёт реальный axios.
- **Нетестируемые синглтоны / общее состояние:** модульная `Map chains` (`page-lock.ts:11`) — состояние течёт
между тестами в одном воркере (изолировать `pageId`/`vi.resetModules()`); `Math.random()` в
`node-ops.ts:591` (`makeFreshId`) и `transforms.ts:240` (`freshId`) — недетерминизм id.
- **Порядко-зависимые тесты (риск):** чтение `process.env.DEBUG` в `auth-utils.ts` (set/unset + restore);
глобалы collaboration; общая `chains`-Map.
- **Артефакт покрытия dist-vs-src:** `main: dist/index.js` → тесты исполняют скомпилированный код, v8 меряет
`src` → ложные 0 % (см. §1).
- **Чистая логика в ловушке `async main()`:** `pull.ts`/`roundtrip.ts` — поэтому 0 %/19 % при наличии
тестируемой чистой логики.
- **`node:vm` исполняет пользовательский JS** (`client.ts::transformPage`, ~2491) — security, нужен явный тест
на отсутствие escape (`require`/`process`/`fs`) и таймаут.
- **Проглоченные ошибки:** `diff.ts:172` (`catch{return ""}` маскирует баг резолвера позиции);
пустые `catch` в cleanup collaboration. Статичный `lastIndex` regex `/g` в `transforms.ts:216`.
- **Нестабильные тесты (CI-история):** н/д — CI-история отказов отсутствует (проект на Increment 1,
тесты только базовые); пункт неактуален сейчас.
## 5. Необходимые рефакторинги перед написанием тестов
- **R-App-1** — извлечь `folderSegmentsFor` на верхний уровень + экспортировать `nameForNode`.
Блокирует: unit-тесты путей `pull.ts` (коллизии, защита от цикла).
- **R-App-2** — экспортировать `parseArgs` и `firstDivergence` (`roundtrip.ts`).
Блокирует: unit-тесты дивергенции и парсинга аргументов.
- **R-App-4** — инъекция клиента + fs в `pull.main`. Блокирует: integration-тест `pull.main`.
- **R-Client-1** — инъекция HTTP-клиента (axios-instance + multipart-poster).
Блокирует: все integration-тесты REST (auth-реавторизация, uploadImage, createPage, checkNewComments).
- **R-Client-2** — извлечь pure-функции маппинга ответов/конвертов/clamp.
Блокирует: перевод ~15 кейсов из integration в быстрый unit (`paginateAll`, list-endpoints).
- **R-Client-3** — инъекция collab-транспорта (`mutatePageContent`/provider-factory).
Блокирует: unit-тесты оркестрации patch/insert/delete/table/comment.
- **R-Client-4** — поднять чистые валидаторы (`isSafeUrl`/`validateDocUrls`/`validateDocStructure`/
`imageMimeFromPath`/`buildImageNode`) в `lib/` или экспортировать. Блокирует: XSS-unit-тесты (высший приоритет).
- **R-Collab-1** — извлечь тело `onSynced` в чистую `applyTransformToYdoc(ydoc, transform)`.
Блокирует: unit ядра read-transform-write (потеря данных).
- **R-Collab-2 + R-Collab-3** — инъекция provider-factory и часов (fake-таймеры).
Блокируют: тесты ложного успеха/таймаутов и integration `mutatePageContent`.
- **R-NodeOps / R-Transforms** — инъекция `makeFreshId`/`freshId` (опционально).
Блокируют: только точные ассерты на id; без рефактора — проверять формат+уникальность.
- **Инфраструктура** — добавить `@vitest/coverage-v8` + скрипт `"coverage"`; alias `docmost-client→src`
в `vitest.config.ts`; dev-deps `fast-check` (property) и mock-ws/msw (integration). Эти изменения
трогают конфиги/`package.json` — вне правки данного отчёта, заложить в Фазу 1.
## 6. План внедрения (по фазам)
- **Фаза 1 — чистые unit, нулевой/малый рефактор (наивысший ROI).** node-ops (8), transforms (5–6),
diff (5), client-utils (6), guards схемы (2), golden-матрица + envelope конвертера (3),
чистый верх collaboration (5), чистые валидаторы клиента после R-Client-4 (XSS + structure + image),
расширение `collectRecentSince`, app-root после R-App-1/2 (5). Плюс инфраструктура покрытия/alias.
*ROI:* мгновенно поднимает покрытие самой дефектоопасной чистой логики (потеря данных, XSS) почти без риска.
- **Фаза 2 — корона: property-тест round-trip Markdown + фабрика документов.** Ловит фантомные git-диффы и
неидемпотентность — главный класс дефектов всего инструмента синхронизации.
- **Фаза 3 — refactor-gated unit.** R-Collab-1 → ядро `onSynced` (потеря данных при конкуренции);
R-Client-2 → unit пагинации/list-endpoints; sandbox `transformPage` (security).
- **Фаза 4 — integration с DI.** R-Client-1/3 → авто-реавторизация, uploadImage, createPage, checkNewComments;
R-Collab-2/3 → подавление ложного успеха + e2e против mock-WS; R-App-4 → `pull.main`.
- **Фаза 5 — contract + E2E.** 1 contract-набор против закреплённой версии Docmost;
**2 E2E-смоука** против `docker-compose` Docmost — user journeys: (1) «pull пространства в vault»
(страницы → файлы с верной иерархией), (2) «round-trip страницы без фантомного diff».
## 7. Источники
- Отчёты **9** субагентов `module-testability-analyst` (app-root, client-core, markdown-conversion,
prosemirror-schema, node-ops, collaboration, transforms, diff, client-utils).
- Вывод coverage-инструмента: `vitest run --coverage` (provider v8), запущен оркестратором лично;
6 тест-файлов / 33 теста зелёные; overall 2.6 % statements (артефакт dist-vs-src учтён).
- **Фильтрация предложений:**
- Шаг 1 (кросс-модульный дедуп): снято ≈ 20 (поведение схемы → round-trip; делегаты client.ts →
модули node-ops/converter/diff; `blockText` → node-ops).
- Шаг 2 (skip-list): снято ≈ 40 (декларативные spec-объекты схемы ~26, плоские фильтры 3,
type-only интерфейсы, framework-обвязка, `index.ts`, passthrough-обёртки).
- Шаг 3 (бюджет пирамиды): E2E сведены к 2; множество per-endpoint integration свёрнуты в `paginateAll`
+ представительные кейсы.
- Шаг 6 (adversarial): оставлены только тесты с конкретными ассертами, падающими при реалистичной
поломке (отказ XSS-схем, байт-равенство round-trip, порядок перенумерации сносок); тавтологичные
ассерты атрибутов схемы отброшены.