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).
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-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.ts0 %, хотяcollectRecentSinceреально исполняется. Перед измерением покрытия добавить вvitest.config.tsaliasdocmost-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; sandboxtransformPage(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, matha < b, mention/attachment/callout/details/columns/медиа, hr, hard break, неизвестный тип, пустой doc →""); идемпотентность экранирования (escapeAttrстабилен на& ",encodeMdUrlпробел→%20), отступы вложенных списков (indentItemChildren); envelopeparseDocmostMarkdown/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.createspec-объекты (~26 шт.) и триплетыdefault/parseHTML/renderHTML— декларативные данные, тест тавтологичен; поведение узлов проверяется косвенно через round-trip (другой модуль). ClosurestextStyle.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(#nin/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(покрыты транзитивно); недостижимый fallbackstructuredClone.
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): ядро
onSyncedread-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, окружающие марки сохранены, scopebeforeBlock);setCalloutRange(статичность regexlastIndexна двух text-узлах, только внутри callout, Unicode…и ASCII...);mdToInlineNodes(срез префиксовкомментарий:/N., граница**bold**-лида, пробел не теряется);walk/getList(полнота обхода, live-ссылка не клон). - Извлечь/рефактор: инъекция
freshId(:240) — опционально, для воспроизводимого dryRun. - НЕ тестировать:
blockText(re-exportnode-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. СтатичныйlastIndexregex/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"; aliasdocmost-client→srcвvitest.config.ts; dev-depsfast-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; sandboxtransformPage(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-composeDocmost — 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, порядок перенумерации сносок); тавтологичные ассерты атрибутов схемы отброшены.
- Шаг 1 (кросс-модульный дедуп): снято ≈ 20 (поведение схемы → round-trip; делегаты client.ts →
модули node-ops/converter/diff;