Отчет редтима, написать тесты, потенциально баги #206

Closed
opened 2026-06-26 01:04:58 +03:00 by vvzvlad · 1 comment
Owner

attach-1 — Дублирование страницы ломает картинки, общие для нескольких страниц · high / high / moderate

  • Атака: на странице A картинка (attachmentId=X); тот же блок скопирован в дочернюю страницу B
    (attachmentId остаётся X). Дублировать корень A. Открыть копии A' и B'.
  • Гипотеза: attachmentMap (одна Map на всё поддерево) ключуется по attachmentId. Для B
    attachmentMap.set(X, {oldPageId:B,…}) затирает запись A. Дальше при копировании блоба строка
    if (attachment.pageId(A) !== pageAttachment.oldPageId(B)) continue → копия пропускается,
    новый attachments-row не создаётся, storageService.copy не зовётся. Обе копии картинок 404.
  • Как проверить: unit на duplicatePage с деревом из 2 страниц, ссылающихся на один attachmentId:
    ожидать 2 новых attachment-row и резолвящиеся src; сейчас — 0 строк, copy не вызывается.
  • Доказательства: page.service.ts:604 (общая Map), :620 (set по attachmentId), :826-828 (guard пропускает копию).
  • Spot-check: ПОДТВЕРЖДЕНО — Map ключуется по attachmentId, guard attachment.pageId !== oldPageId на 826.

persist-1 — Сбой БД при автосейве проглатывается, правка теряется молча · high / medium / cheap

  • Атака: транзиентный сбой updatePage/транзакции при store (дедлок, разрыв коннекта,
    serialization failure). Затем последний клиент отключается, документ выгружается.
  • Гипотеза: весь executeTx обёрнут в try/catch, который только logger.error. Функция
    возвращается «успешно», Hocuspocus считает, что сохранил, выгружает (destroy) in-memory Y.Doc —
    единственную копию новой правки. При следующей загрузке читается старый ydoc. Ретрая нет.
  • Как проверить: unit с updatePage, кидающим один раз; ассертить, что onStoreDocument НЕ пробрасывает
    ошибку и контент не записан (Hocuspocus примет это за успех). Целевой контракт — rethrow/повторный enqueue.
  • Доказательства: persistence.extension.ts:184-261 (catch+log), @hocuspocus/server …:2520-2538
    (на resolve выгружает), :2600-2601 (destroy). Среда, не действие пользователя, но реальная потеря данных.

ui-state-races-1 — Вне-очередное moveTreeNode роняет поддерево (нет cycle-guard в reducer) · high / medium / cheap

  • Атака: два события прилетают в порядке, при котором parentId оказывается внутри перемещаемого
    поддерева (сервер двинул X под Y, затем Y под X; на приёмнике Y ещё внутри X). Достижимо при двух
    пользователях или быстром reorder + серверном эхо.
  • Гипотеза: treeModel.move (drag-drop) имеет isDescendant-guard, а placeByPosition
    (через который идёт socket-reducer applyMoveTreeNode) — нет. remove удаляет узел со ВСЕМ
    поддеревом (включая будущего родителя), insert с отсутствующим parentId возвращает дерево без
    изменений → перемещаемый узел и все потомки молча исчезают из локального дерева, без ошибки/refetch.
  • Как проверить: unit в tree-socket-reducers.test.ts: положить родителя под собственного ребёнка,
    ассертить, что find(next,'a') и find(next,'b') оба null (текущее поведение).
  • Доказательства: tree-model.ts:289-302 (placeByPosition без guard), :100-101 (insert no-op при
    отсутствии parent), :314 (move ИМЕЕТ guard — пути разошлись), tree-socket-reducers.ts:84-94.
  • Spot-check: ПОДТВЕРЖДЕНОplaceByPosition проверяет только find(source)/find(parent), без isDescendant.

mdrt-2 — Экспорт в Markdown молча выкидывает узлы без turndown-правила · high / high / cheap

  • Атака: страница с transclusionReference, mention, status, pageBreak → Export to Markdown /
    copy-as-markdown. transclusionReference и pageBreak исчезают полностью; mention/status теряют
    идентичность (data-id/color).
  • Гипотеза: turndown-правила заданы только для фиксированного набора (callout, taskItem, details,
    math, iframe, htmlEmbed, image, video, footnote). Остальные кастом-узлы попадают в дефолтную обработку:
    пустой div = «blank» и удаляется, обёртка отдаёт только текст. Нарушен инвариант «никогда молча не терять блок».
  • Как проверить: unit htmlToMarkdown('<div data-type="transclusionReference" data-id="abc"></div>') → ''.
    Round-trip mention: PM→HTML→MD→HTML→JSON, ассертить выживание узла.
  • Доказательства: turndown.utils.ts:55-72 (фикс-набор правил), transclusion-reference.ts:61-68,
    mention.ts:273-285.

persist-6 — Пустой live-документ перезаписывает непустой контент · high / medium / cheap

  • Атака: live Y.Doc на мгновение сериализуется в пустой/почти пустой (баг клиента/агента, неудачный
    merge, опустошающая транклюзия) на странице с контентом; срабатывает debounce-store.
  • Гипотеза: в onStoreDocument НЕТ empty-guard перед updatePage (в отличие от onLoadDocument).
    Единственные стопы — !page и isDeepStrictEqual. isEmptyParagraphDoc используется только для
    решения о boundary-снимке, а не для блокировки записи. Пустой контент проходит и затирает страницу;
    boundary-снимок защищает только переход user→agent.
  • Как проверить: unit: findById возвращает богатый контент; передать Y.Doc, дающий пустой doc,
    actor='user'; ассертить, что updatePage вызван с пустым контентом (страница затёрта).
  • Доказательства: persistence.extension.ts:192-255 (нет guard), :234 (isEmptyParagraphDoc только для снимка).

editor-pm-7 — Коллизии unique-id при copy/paste/duplicate блока · medium / medium / moderate

  • Атака: скопировать heading/paragraph и вставить в тот же документ, или продублировать блок через
    bulk-JSON, сохранив attrs.id. Получаются два блока с одинаковым id; адресная правка (MCP patch_node/
    delete_node «before/after id») бьёт не по тому/по обоим узлам.
  • Гипотеза: UniqueID настроен только для heading/paragraph/transclusionSource; серверный
    addUniqueIdsToDoc только ДОБАВЛЯет id там, где их нет, и не дедуплицирует существующие. Узлы вне
    набора (callout, column, ячейки таблицы) вообще без стабильного id.
  • Как проверить: unit: doc с двумя параграфами с одинаковым id прогнать через addUniqueIdsToDoc,
    ассертить, что дедупликации НЕ происходит; затем MCP patch_node по этому id.
  • Доказательства: extensions.ts:192-195, collaboration.util.ts:131-138, unique-id.ts:4-11.

mdrt-5 — Сноски при импорте: порядок и осиротевшие определения · medium / high / cheap

  • Атака: импорт markdown, где ссылки и определения сносок идут в разном порядке + есть определение
    без ссылки ([^z]).
  • Гипотеза: extractFootnoteDefinitions выдаёт div’ы определений в порядке СТРОК, а ссылки остаются
    в порядке появления → нумерация разъезжается; осиротевшие определения выводятся безусловно.
    Плагин-синхронизатор сносок работает только на локальных правках и пропускает remote/import →
    несогласованное состояние персистится, а при первой правке пользователя осиротевшая сноска внезапно
    исчезает (отложенное «само изменилось»).
  • Как проверить: unit на extractFootnoteDefinitions('First[^b] then[^a].\n\n[^a]:…\n[^b]:…')
    порядок def ['a','b'] vs ref ['b','a']; ассертить совпадение (упадёт). Отдельно — orphan [^z] не должен попадать.
  • Доказательства: footnote.marked.ts:99-125, footnote-sync.ts:210-224.

vhs-1 / vhs-3 / vhs-11 — Снимок истории мис-тегается human↔agent из-за re-read контента · high / medium / moderate

  • Атака: на «возрастной» странице человек печатает (debounce-снимок отложен до 5 мин); до срабатывания
    воркера прилетает агентская правка (delay-0 снимок + перезапись строки страницы). Когда отложенный
    «человеческий» джоб срабатывает, воркер findById читает ТЕКУЩИЙ (агентский) контент и сохраняет его
    с provenance из строки = 'agent'. Промежуточная человеческая версия не снимается вообще.
  • Гипотеза: воркер истории перечитывает контент и lastUpdatedSource в момент запуска, без привязки
    к контенту на момент enqueue. Окно человеческого debounce (до 5 мин) сильно больше интервала правок,
    так что «человеческий» джоб рутинно снимает то, что в строке на момент срабатывания.
  • Как проверить: интеграционно: enqueue human-джоб с C_human/'user'; до запуска updatePage на
    C_agent/'agent'; запустить воркер; ассертить контент/provenance снимка (получится C_agent/'agent').
  • Доказательства: history.processor.ts:44-46, page-history.repo.ts:81, constants.ts:1,
    persistence.extension.ts:60-72 (комментарий сам признаёт окно).

test-pipeline-1 — Ветка «сбой БД при store» недостижима в тестах

  • executeTx в споке мокнут так, что transaction().execute(fn)=>fn(trxStub) никогда не падает, а
    logger.error застаблен → молчаливая потеря правки (persist-1) не ассертится ничем.
    persistence-store.spec.ts:57-62,119-122.

test-pipeline-2 — Гонка delay-0 снимка проверена только как арифметика

  • compute-history-job.spec.ts проверяет delay===0 и строку jobId; реальное переплетение
    (agent enqueue → human updatePage → worker findById) не воспроизводится. :22-30, history.processor.spec.ts:57,121-143.

test-pipeline-3 — Многонодовый redis-sync (378 строк) без единого теста; CI одно-нодовый

  • Локи владения, proxy-сокеты, pub/sub fan-out не покрыты; CI поднимает один redis и никогда два
    collab-узла. redis-sync.extension.ts:35-56, .github/workflows/test.yml:41-49,71-79.

test-pipeline-4 — Конверсия Yjs/PM тестируется только на одном документе

  • Нет теста сходимости двух клиентов и нет ассерта на «mark не перетекает через границу codeBlock».
    collaboration.util.spec.ts:182-243.

test-pipeline-5 — Сбой jsonToText молча обнуляет textContent (поиск), не ассертится

  • В onStoreDocument textContent дефолт null, ставится в try/catch; на throw остаётся null и пишется в БД,
    затирая полнотекст. grep jsonToText … *.spec.ts пусто. persistence.extension.ts:166-172,245.

test-pipeline-6 — Нет ни одного e2e/RTL живого редактора; reconciliation дерева не тестится

  • Все editor-тесты vi.mock('@tiptap/react') → живой ProseMirror/Yjs нигде не запускается; jest-e2e.json
    есть, но CI его не зовёт; reducers дерева тестятся изолированно, без сценария «оптимистично + авторитетное
    событие». footnote-views.structure.test.tsx:33-39, apps/server/package.json:27,32,171.

test-pipeline-7 — Нет порога покрытия; round-trip сносок проверяет подстроки, а не структуру

  • Coverage-gate отсутствует; footnote-markdown.test.ts ассертит toContain(...), а не структурную
    эквивалентность/порядок/отсутствие дублей. .github/workflows/test.yml:71-79, vitest.config.ts:11-15.

test-pipeline-8 — Интеграционный суит maxWorkers:1 на одной БД → contention/row-lock не вскрыть

  • FOR UPDATE в onStoreDocument никогда не проверяется на конкурентность. jest-integration.json:13-15,
    persistence.extension.ts:186-190.
#### attach-1 — Дублирование страницы ломает картинки, общие для нескольких страниц · high / high / moderate - **Атака:** на странице A картинка (attachmentId=X); тот же блок скопирован в дочернюю страницу B (attachmentId остаётся X). Дублировать корень A. Открыть копии A' и B'. - **Гипотеза:** `attachmentMap` (одна Map на всё поддерево) ключуется по `attachmentId`. Для B `attachmentMap.set(X, {oldPageId:B,…})` затирает запись A. Дальше при копировании блоба строка `if (attachment.pageId(A) !== pageAttachment.oldPageId(B)) continue` → копия пропускается, новый attachments-row не создаётся, `storageService.copy` не зовётся. Обе копии картинок 404. - **Как проверить:** unit на `duplicatePage` с деревом из 2 страниц, ссылающихся на один attachmentId: ожидать 2 новых attachment-row и резолвящиеся src; сейчас — 0 строк, copy не вызывается. - **Доказательства:** `page.service.ts:604` (общая Map), `:620` (`set` по attachmentId), `:826-828` (guard пропускает копию). - **Spot-check:** **ПОДТВЕРЖДЕНО** — Map ключуется по attachmentId, guard `attachment.pageId !== oldPageId` на 826. #### persist-1 — Сбой БД при автосейве проглатывается, правка теряется молча · high / medium / cheap - **Атака:** транзиентный сбой `updatePage`/транзакции при store (дедлок, разрыв коннекта, serialization failure). Затем последний клиент отключается, документ выгружается. - **Гипотеза:** весь `executeTx` обёрнут в `try/catch`, который только `logger.error`. Функция возвращается «успешно», Hocuspocus считает, что сохранил, выгружает (destroy) in-memory Y.Doc — единственную копию новой правки. При следующей загрузке читается старый ydoc. Ретрая нет. - **Как проверить:** unit с `updatePage`, кидающим один раз; ассертить, что onStoreDocument НЕ пробрасывает ошибку и контент не записан (Hocuspocus примет это за успех). Целевой контракт — rethrow/повторный enqueue. - **Доказательства:** `persistence.extension.ts:184-261` (catch+log), `@hocuspocus/server …:2520-2538` (на resolve выгружает), `:2600-2601` (destroy). *Среда, не действие пользователя, но реальная потеря данных.* #### ui-state-races-1 — Вне-очередное moveTreeNode роняет поддерево (нет cycle-guard в reducer) · high / medium / cheap - **Атака:** два события прилетают в порядке, при котором `parentId` оказывается внутри перемещаемого поддерева (сервер двинул X под Y, затем Y под X; на приёмнике Y ещё внутри X). Достижимо при двух пользователях или быстром reorder + серверном эхо. - **Гипотеза:** `treeModel.move` (drag-drop) имеет `isDescendant`-guard, а `placeByPosition` (через который идёт socket-reducer `applyMoveTreeNode`) — **нет**. `remove` удаляет узел со ВСЕМ поддеревом (включая будущего родителя), `insert` с отсутствующим parentId возвращает дерево без изменений → перемещаемый узел и все потомки молча исчезают из локального дерева, без ошибки/refetch. - **Как проверить:** unit в `tree-socket-reducers.test.ts`: положить родителя под собственного ребёнка, ассертить, что `find(next,'a')` и `find(next,'b')` оба `null` (текущее поведение). - **Доказательства:** `tree-model.ts:289-302` (placeByPosition без guard), `:100-101` (insert no-op при отсутствии parent), `:314` (move ИМЕЕТ guard — пути разошлись), `tree-socket-reducers.ts:84-94`. - **Spot-check:** **ПОДТВЕРЖДЕНО** — `placeByPosition` проверяет только `find(source)`/`find(parent)`, без `isDescendant`. #### mdrt-2 — Экспорт в Markdown молча выкидывает узлы без turndown-правила · high / high / cheap - **Атака:** страница с `transclusionReference`, `mention`, `status`, `pageBreak` → Export to Markdown / copy-as-markdown. transclusionReference и pageBreak исчезают полностью; mention/status теряют идентичность (data-id/color). - **Гипотеза:** turndown-правила заданы только для фиксированного набора (callout, taskItem, details, math, iframe, htmlEmbed, image, video, footnote). Остальные кастом-узлы попадают в дефолтную обработку: пустой div = «blank» и удаляется, обёртка отдаёт только текст. Нарушен инвариант «никогда молча не терять блок». - **Как проверить:** unit `htmlToMarkdown('<div data-type="transclusionReference" data-id="abc"></div>')` → ''. Round-trip mention: PM→HTML→MD→HTML→JSON, ассертить выживание узла. - **Доказательства:** `turndown.utils.ts:55-72` (фикс-набор правил), `transclusion-reference.ts:61-68`, `mention.ts:273-285`. #### persist-6 — Пустой live-документ перезаписывает непустой контент · high / medium / cheap - **Атака:** live Y.Doc на мгновение сериализуется в пустой/почти пустой (баг клиента/агента, неудачный merge, опустошающая транклюзия) на странице с контентом; срабатывает debounce-store. - **Гипотеза:** в `onStoreDocument` НЕТ empty-guard перед `updatePage` (в отличие от `onLoadDocument`). Единственные стопы — `!page` и `isDeepStrictEqual`. `isEmptyParagraphDoc` используется только для решения о boundary-снимке, а не для блокировки записи. Пустой контент проходит и затирает страницу; boundary-снимок защищает только переход user→agent. - **Как проверить:** unit: findById возвращает богатый контент; передать Y.Doc, дающий пустой doc, actor='user'; ассертить, что updatePage вызван с пустым контентом (страница затёрта). - **Доказательства:** `persistence.extension.ts:192-255` (нет guard), `:234` (isEmptyParagraphDoc только для снимка). #### editor-pm-7 — Коллизии unique-id при copy/paste/duplicate блока · medium / medium / moderate - **Атака:** скопировать heading/paragraph и вставить в тот же документ, или продублировать блок через bulk-JSON, сохранив `attrs.id`. Получаются два блока с одинаковым id; адресная правка (MCP patch_node/ delete_node «before/after id») бьёт не по тому/по обоим узлам. - **Гипотеза:** UniqueID настроен только для heading/paragraph/transclusionSource; серверный `addUniqueIdsToDoc` только ДОБАВЛЯет id там, где их нет, и не дедуплицирует существующие. Узлы вне набора (callout, column, ячейки таблицы) вообще без стабильного id. - **Как проверить:** unit: doc с двумя параграфами с одинаковым id прогнать через `addUniqueIdsToDoc`, ассертить, что дедупликации НЕ происходит; затем MCP patch_node по этому id. - **Доказательства:** `extensions.ts:192-195`, `collaboration.util.ts:131-138`, `unique-id.ts:4-11`. #### mdrt-5 — Сноски при импорте: порядок и осиротевшие определения · medium / high / cheap - **Атака:** импорт markdown, где ссылки и определения сносок идут в разном порядке + есть определение без ссылки (`[^z]`). - **Гипотеза:** `extractFootnoteDefinitions` выдаёт div’ы определений в порядке СТРОК, а ссылки остаются в порядке появления → нумерация разъезжается; осиротевшие определения выводятся безусловно. Плагин-синхронизатор сносок работает только на локальных правках и пропускает remote/import → несогласованное состояние персистится, а при первой правке пользователя осиротевшая сноска внезапно исчезает (отложенное «само изменилось»). - **Как проверить:** unit на `extractFootnoteDefinitions('First[^b] then[^a].\n\n[^a]:…\n[^b]:…')` → порядок def ['a','b'] vs ref ['b','a']; ассертить совпадение (упадёт). Отдельно — orphan `[^z]` не должен попадать. - **Доказательства:** `footnote.marked.ts:99-125`, `footnote-sync.ts:210-224`. #### vhs-1 / vhs-3 / vhs-11 — Снимок истории мис-тегается human↔agent из-за re-read контента · high / medium / moderate - **Атака:** на «возрастной» странице человек печатает (debounce-снимок отложен до 5 мин); до срабатывания воркера прилетает агентская правка (delay-0 снимок + перезапись строки страницы). Когда отложенный «человеческий» джоб срабатывает, воркер `findById` читает ТЕКУЩИЙ (агентский) контент и сохраняет его с provenance из строки = 'agent'. Промежуточная человеческая версия не снимается вообще. - **Гипотеза:** воркер истории перечитывает контент и `lastUpdatedSource` в момент запуска, без привязки к контенту на момент enqueue. Окно человеческого debounce (до 5 мин) сильно больше интервала правок, так что «человеческий» джоб рутинно снимает то, что в строке на момент срабатывания. - **Как проверить:** интеграционно: enqueue human-джоб с C_human/'user'; до запуска `updatePage` на C_agent/'agent'; запустить воркер; ассертить контент/provenance снимка (получится C_agent/'agent'). - **Доказательства:** `history.processor.ts:44-46`, `page-history.repo.ts:81`, `constants.ts:1`, `persistence.extension.ts:60-72` (комментарий сам признаёт окно). #### test-pipeline-1 — Ветка «сбой БД при store» недостижима в тестах - `executeTx` в споке мокнут так, что `transaction().execute(fn)=>fn(trxStub)` никогда не падает, а `logger.error` застаблен → молчаливая потеря правки (persist-1) не ассертится ничем. `persistence-store.spec.ts:57-62,119-122`. #### test-pipeline-2 — Гонка delay-0 снимка проверена только как арифметика - `compute-history-job.spec.ts` проверяет `delay===0` и строку jobId; реальное переплетение (agent enqueue → human updatePage → worker findById) не воспроизводится. `:22-30`, `history.processor.spec.ts:57,121-143`. #### test-pipeline-3 — Многонодовый redis-sync (378 строк) без единого теста; CI одно-нодовый - Локи владения, proxy-сокеты, pub/sub fan-out не покрыты; CI поднимает один redis и никогда два collab-узла. `redis-sync.extension.ts:35-56`, `.github/workflows/test.yml:41-49,71-79`. #### test-pipeline-4 — Конверсия Yjs/PM тестируется только на одном документе - Нет теста сходимости двух клиентов и нет ассерта на «mark не перетекает через границу codeBlock». `collaboration.util.spec.ts:182-243`. #### test-pipeline-5 — Сбой jsonToText молча обнуляет textContent (поиск), не ассертится - В `onStoreDocument` `textContent` дефолт null, ставится в try/catch; на throw остаётся null и пишется в БД, затирая полнотекст. `grep jsonToText … *.spec.ts` пусто. `persistence.extension.ts:166-172,245`. #### test-pipeline-6 — Нет ни одного e2e/RTL живого редактора; reconciliation дерева не тестится - Все editor-тесты `vi.mock('@tiptap/react')` → живой ProseMirror/Yjs нигде не запускается; `jest-e2e.json` есть, но CI его не зовёт; reducers дерева тестятся изолированно, без сценария «оптимистично + авторитетное событие». `footnote-views.structure.test.tsx:33-39`, `apps/server/package.json:27,32,171`. #### test-pipeline-7 — Нет порога покрытия; round-trip сносок проверяет подстроки, а не структуру - Coverage-gate отсутствует; `footnote-markdown.test.ts` ассертит `toContain(...)`, а не структурную эквивалентность/порядок/отсутствие дублей. `.github/workflows/test.yml:71-79`, `vitest.config.ts:11-15`. #### test-pipeline-8 — Интеграционный суит maxWorkers:1 на одной БД → contention/row-lock не вскрыть - `FOR UPDATE` в `onStoreDocument` никогда не проверяется на конкурентность. `jest-integration.json:13-15`, `persistence.extension.ts:186-190`.
vvzvlad added the bugtest labels 2026-06-26 01:05:26 +03:00
Collaborator

Закрываю как выполненное по сути. Большинство находок редтима исправлено и покрыто тестами в PR #212; остаток покрыт регрессионными тестами в PR #230. Два реально подтверждённых бага потери данных (mdrt-2 — экспорт MD молча теряет ноды; persist-6 — пустой live-документ затирает контент) осознанно оставлены неисправленными (их фикс — изменение поведения) и задокументированы характеризующими тестами (it.fails/it.failing). Чтобы этот долг не потерялся, он вынесен в отдельный issue: #244.

Закрываю как выполненное по сути. Большинство находок редтима исправлено и покрыто тестами в PR #212; остаток покрыт регрессионными тестами в PR #230. Два реально подтверждённых бага потери данных (mdrt-2 — экспорт MD молча теряет ноды; persist-6 — пустой live-документ затирает контент) осознанно оставлены неисправленными (их фикс — изменение поведения) и задокументированы характеризующими тестами (it.fails/it.failing). Чтобы этот долг не потерялся, он вынесен в отдельный issue: #244.
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#206