Files
docmost-sync/test-strategy-report.md
vvzvlad 447d2508ae feat(sync): scaffold monorepo, extract docmost-client, add Phase-0 harness + read-only pull
Lock the access-layer decision (REST only) and start implementation per SPEC.

- monorepo (npm workspaces): packages/docmost-client = DocmostClient + lib/*
  copied 1:1 from docmost-mcp/src (backport target), plus bannered sync methods
  (listTrash, restorePage, listAllSpacePages, exportPageBody, listRecentSince /
  collectRecentSince cursor scan)
- engine stays the root app per AGENTS.md (src/, test/, build/, data/, settings.ts);
  add roundtrip.ts (SPEC §11 idempotency harness), pull.ts (SPEC §6 read-only
  Docmost->FS mirror), sanitize.ts (SPEC §12 filenames, path-traversal-safe)
- Dockerfile builds the workspace lib before the app; vitest gates CI
- exportPageBody never touches /comments (SPEC §3); serializeDocmostMarkdownBody
  emits meta + body only
- SPEC: resolve access-layer (REST), reflect root-engine layout + REST pagination
- tests: sanitize (incl. dot-traversal), collectRecentSince (cutoff/dedup/cap),
  stripBlockIds, markdown round-trip byte-stability

Note: raw ProseMirror round-trip is byte-stable in Markdown but not yet attribute-
idempotent (SPEC §11 Задача №0, before Phase 2).
2026-06-16 20:20:20 +03:00

28 KiB

Отчёт по тест-стратегии — 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-client0 % (поведенчески покрыт лишь 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 (точное имя authTokenauthTokenRefresh, base64-= не обрезается); filterPage (условный spread: content === "" включается, не-string опускается); getCollabToken (распаковка data.data.tokendata.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, порядок перенумерации сносок); тавтологичные ассерты атрибутов схемы отброшены.