Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 382e5196da | |||
| 76e0c08cec | |||
| 8978d69f3e | |||
| c192f2a2e1 | |||
| d78b985062 | |||
| 2ce672709a | |||
| a4fc6c7f64 | |||
| c252068672 | |||
| 68caf8157a | |||
| cb9c5dda59 | |||
| e431b33bb1 | |||
| 4369bbc53d | |||
| 8e5ad8070b | |||
| cfc105c7d6 | |||
| d7fa6738e5 | |||
| e6d8eda8e5 | |||
| 8d8ecaed82 | |||
| eacc1c4811 | |||
| 8e12aa8ebf | |||
| 348dcd0802 | |||
| 086bc1bf8b | |||
| 77b245461f | |||
| 77c64c4fd9 | |||
| 2bb71c1a45 | |||
| 20248b8c95 | |||
| 9274c51053 | |||
| 94f60cf0ec | |||
| 40d42d61e6 | |||
| f13105333a | |||
| baa41d66ad | |||
| 52beae85b3 |
@@ -202,6 +202,13 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# Default 900000 (15 min).
|
# Default 900000 (15 min).
|
||||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||||
|
|
||||||
|
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
|
||||||
|
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
|
||||||
|
# each step; deferred tools (the fat/rare ones + all external MCP tools) load on
|
||||||
|
# demand. Set AI_CHAT_DEFERRED_TOOLS=false to restore the old "all tools always
|
||||||
|
# active" behavior.
|
||||||
|
# AI_CHAT_DEFERRED_TOOLS=true
|
||||||
|
|
||||||
# --- Anonymous public-share AI assistant ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ jobs:
|
|||||||
- name: Build editor-ext
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
# @docmost/prosemirror-markdown is the shared converter (#293/#326); its
|
||||||
|
# build/ is gitignored, and plain `pnpm -r test` does NOT honour nx
|
||||||
|
# `dependsOn: ^build`, so its consumers (mcp `pretest: tsc`, git-sync vitest
|
||||||
|
# typecheck) fail with TS2307 Cannot find module '@docmost/prosemirror-markdown'
|
||||||
|
# unless it is built first. Build it before the recursive test run.
|
||||||
|
- name: Build prosemirror-markdown
|
||||||
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pnpm -r test
|
run: pnpm -r test
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
|||||||
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
||||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
||||||
|
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp` and `git-sync`; there is exactly ONE copy of the converter now |
|
||||||
|
|
||||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||||
|
|
||||||
@@ -282,7 +283,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
### Client structure
|
### Client structure
|
||||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by both `mcp` and `git-sync` — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
|
|
||||||
|
|||||||
+24
-2
@@ -5,6 +5,13 @@ RUN npm install -g pnpm@10.4.0
|
|||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
|
# re2 (packages/mcp) always compiles from source under pnpm (the prebuilt-binary
|
||||||
|
# download cannot identify the GitHub repo), so node-gyp needs python3/make/g++.
|
||||||
|
# This stage is discarded, so the toolchain can stay installed.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -38,6 +45,14 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
|||||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||||
|
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
||||||
|
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
||||||
|
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
||||||
|
# install resolves a broken workspace symlink and every ai-chat tool dies with
|
||||||
|
# ERR_MODULE_NOT_FOUND (#293/#326 step 5). (git-sync has no runtime consumer yet;
|
||||||
|
# revisit at step 6 when #119 lands.)
|
||||||
|
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||||
|
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||||
|
|
||||||
# Copy root package files
|
# Copy root package files
|
||||||
COPY --from=builder /app/package.json /app/package.json
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
@@ -49,9 +64,16 @@ COPY --from=builder /app/patches /app/patches
|
|||||||
|
|
||||||
RUN chown -R node:node /app
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
USER node
|
# Toolchain is needed transiently to compile re2 during the prod install; install
|
||||||
|
# and purge it in one layer to keep the final image slim. The install itself runs
|
||||||
|
# as the node user via su to keep node_modules ownership without a costly chown layer.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& su node -c "pnpm install --frozen-lockfile --prod" \
|
||||||
|
&& apt-get purge -y --auto-remove python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
USER node
|
||||||
|
|
||||||
RUN mkdir -p /app/data/storage
|
RUN mkdir -p /app/data/storage
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ roles:
|
|||||||
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. When a figure, name, term, or version to check recurs across the page, use search_in_page to find every occurrence in one call first, then place a targeted comment per hit instead of reading block by block. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||||
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
||||||
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
||||||
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
||||||
@@ -169,7 +169,7 @@ roles:
|
|||||||
- Don't make substantive changes. Edits are minimal and mechanical.
|
- Don't make substantive changes. Edits are minimal and mechanical.
|
||||||
|
|
||||||
HOW TO WORK
|
HOW TO WORK
|
||||||
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important".
|
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important". For a systematic issue that recurs — straight quotes, a hyphen used as a dash, an inconsistent unit or spelling — use search_in_page to list every occurrence in one call first, then leave a targeted comment (with its replacement) on each hit, instead of scanning block by block.
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
|
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ roles:
|
|||||||
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Когда проверяемая цифра, имя, термин или версия встречается по тексту несколько раз, сначала одним вызовом search_in_page найди все вхождения, а затем ставь целевой комментарий на каждое — не читая страницу поблочно. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||||
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
||||||
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
||||||
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
||||||
@@ -170,7 +170,7 @@ roles:
|
|||||||
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
||||||
|
|
||||||
КАК РАБОТАТЬ
|
КАК РАБОТАТЬ
|
||||||
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное».
|
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное». Для систематической ошибки, которая повторяется — прямые кавычки, «е» вместо «ё», дефис вместо тире, неединообразная единица или написание, — сначала одним вызовом search_in_page получи все вхождения, а затем оставь на каждом целевой комментарий с заменой, вместо поблочного просмотра.
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ bundles:
|
|||||||
- slug: line-editor
|
- slug: line-editor
|
||||||
version: 4
|
version: 4
|
||||||
- slug: fact-checker
|
- slug: fact-checker
|
||||||
version: 5
|
version: 6
|
||||||
- slug: proofreader
|
- slug: proofreader
|
||||||
version: 7
|
version: 8
|
||||||
- slug: narrator
|
- slug: narrator
|
||||||
version: 2
|
version: 2
|
||||||
- id: research
|
- id: research
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"fact-checker": {
|
"fact-checker": {
|
||||||
"version": 5,
|
"version": 6,
|
||||||
"hash": "d7769872968109a1ccfb58d71bc3f3564a750b91766156f59031762848de4f24"
|
"hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
|
||||||
},
|
},
|
||||||
"line-editor": {
|
"line-editor": {
|
||||||
"version": 4,
|
"version": 4,
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
||||||
},
|
},
|
||||||
"proofreader": {
|
"proofreader": {
|
||||||
"version": 7,
|
"version": 8,
|
||||||
"hash": "fdf8e0a443fa3c4102095e024146401363629a3f9015fb938c7bac2642825e56"
|
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||||
},
|
},
|
||||||
"researcher": {
|
"researcher": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"axios": "1.16.0",
|
"axios": "1.16.0",
|
||||||
"blueimp-load-image": "5.16.0",
|
"blueimp-load-image": "5.16.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
"diff": "8.0.3",
|
||||||
"dompurify": "3.4.1",
|
"dompurify": "3.4.1",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"highlightjs-sap-abap": "0.3.0",
|
"highlightjs-sap-abap": "0.3.0",
|
||||||
@@ -81,6 +82,7 @@
|
|||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@vitejs/plugin-react": "6.0.1",
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
"eslint": "9.28.0",
|
"eslint": "9.28.0",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-hooks": "7.0.1",
|
"eslint-plugin-react-hooks": "7.0.1",
|
||||||
|
|||||||
@@ -1382,5 +1382,8 @@
|
|||||||
"Applied": "Applied",
|
"Applied": "Applied",
|
||||||
"Suggestion applied": "Suggestion applied",
|
"Suggestion applied": "Suggestion applied",
|
||||||
"Failed to apply suggestion": "Failed to apply suggestion",
|
"Failed to apply suggestion": "Failed to apply suggestion",
|
||||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied."
|
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
||||||
|
"Dismiss": "Dismiss",
|
||||||
|
"Suggestion dismissed": "Suggestion dismissed",
|
||||||
|
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1245,5 +1245,8 @@
|
|||||||
"Applied": "Применено",
|
"Applied": "Применено",
|
||||||
"Suggestion applied": "Предложение применено",
|
"Suggestion applied": "Предложение применено",
|
||||||
"Failed to apply suggestion": "Не удалось применить предложение",
|
"Failed to apply suggestion": "Не удалось применить предложение",
|
||||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено."
|
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
||||||
|
"Dismiss": "Не применять",
|
||||||
|
"Suggestion dismissed": "Предложение отклонено",
|
||||||
|
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||||
@@ -140,3 +140,91 @@ describe("ChatThread — send now (#198)", () => {
|
|||||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The turn-end decision lives in the `onFinish` handler: given the terminal
|
||||||
|
// outcome of a turn (`isAbort` / `isDisconnect` / `isError`, or none = clean),
|
||||||
|
// it decides whether to CONTINUE (flush the next queued message) or END (leave
|
||||||
|
// the queue intact for the user), and which stop notice — if any — to show.
|
||||||
|
// `sendNow` is exercised above; these tests pin down the plain outcomes.
|
||||||
|
describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
h.state.status = "streaming";
|
||||||
|
h.state.onFinish = null;
|
||||||
|
h.state.sendMessage.mockClear();
|
||||||
|
h.state.stop.mockClear();
|
||||||
|
h.state.transport = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drive a fresh onFinish with the given terminal flags after queueing a
|
||||||
|
// message, and report both what the parent was told and whether the queue was
|
||||||
|
// flushed (a resend to the sendMessage spy).
|
||||||
|
function finishWith(flags: {
|
||||||
|
isAbort?: boolean;
|
||||||
|
isDisconnect?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}) {
|
||||||
|
// Tear down any prior render so the loop-driven "every outcome" case does
|
||||||
|
// not leave duplicate queue buttons in the DOM.
|
||||||
|
cleanup();
|
||||||
|
h.state.sendMessage.mockClear();
|
||||||
|
const { onTurnFinished } = renderThread();
|
||||||
|
// Populate the queue while the turn is streaming.
|
||||||
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
|
act(() => {
|
||||||
|
h.state.onFinish?.({
|
||||||
|
message: { id: "a", role: "assistant", parts: [] },
|
||||||
|
isAbort: false,
|
||||||
|
isDisconnect: false,
|
||||||
|
isError: false,
|
||||||
|
...flags,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { onTurnFinished };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("CONTINUES — flushes the next queued message on a clean finish", () => {
|
||||||
|
finishWith({});
|
||||||
|
// Clean finish (no terminal flag): the queued message is auto-sent.
|
||||||
|
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||||
|
// A clean finish shows no stop notice.
|
||||||
|
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
|
||||||
|
finishWith({ isAbort: true });
|
||||||
|
// A plain Stop (not the sendNow interrupt path) must NOT auto-resend: the
|
||||||
|
// queue is preserved for the user to decide.
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText("Response stopped.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENDS — keeps the queue intact on a disconnect and shows the connection-lost notice", () => {
|
||||||
|
finishWith({ isDisconnect: true });
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Connection lost — the answer was interrupted."),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
|
||||||
|
finishWith({ isError: true });
|
||||||
|
// Blindly retrying after a failure would be wrong; the queue is left alone.
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
// isError clears the neutral notice (the error banner covers this case).
|
||||||
|
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the parent on EVERY terminal outcome", () => {
|
||||||
|
// The chat-list refresh / new-chat id adoption must run on success and on
|
||||||
|
// every failure path alike.
|
||||||
|
for (const flags of [
|
||||||
|
{},
|
||||||
|
{ isAbort: true },
|
||||||
|
{ isDisconnect: true },
|
||||||
|
{ isError: true },
|
||||||
|
]) {
|
||||||
|
const { onTurnFinished } = finishWith(flags);
|
||||||
|
expect(onTurnFinished).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// The fallback path renders the full TipTap editor; stub it so we can assert the
|
||||||
|
// safety valve fired without pulling in the editor stack.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||||
|
default: () => <div data-testid="comment-editor-fallback" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mention rendering hits react-query; stub the page/share queries so the mention
|
||||||
|
// case renders in isolation.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CommentContentView } from "./comment-content-view";
|
||||||
|
|
||||||
|
function renderView(content: string | object) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<CommentContentView content={content} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = (content: any[]) => JSON.stringify({ type: "doc", content });
|
||||||
|
const para = (content: any[]) => ({ type: "paragraph", content });
|
||||||
|
const text = (t: string, marks?: any[]) => ({ type: "text", text: t, marks });
|
||||||
|
|
||||||
|
describe("CommentContentView", () => {
|
||||||
|
it("renders paragraphs as <p> with text", () => {
|
||||||
|
const { container } = renderView(doc([para([text("Hello world")])]));
|
||||||
|
expect(screen.getByText("Hello world")).toBeDefined();
|
||||||
|
expect(container.querySelector("p")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces the read-only CommentEditor DOM nesting for CSS parity", () => {
|
||||||
|
const { container } = renderView(doc([para([text("x")])]));
|
||||||
|
// outer .commentEditor > .ProseMirror (module) > .ProseMirror (global) > p
|
||||||
|
const globalPm = container.querySelector("div.ProseMirror > p");
|
||||||
|
expect(globalPm).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the bold mark as <strong>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("bold", [{ type: "bold" }])])]),
|
||||||
|
);
|
||||||
|
const el = container.querySelector("strong");
|
||||||
|
expect(el?.textContent).toBe("bold");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the italic mark as <em>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("it", [{ type: "italic" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("em")?.textContent).toBe("it");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the strike mark as <s>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("st", [{ type: "strike" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("s")?.textContent).toBe("st");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the underline mark as <u> (not the editor fallback)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("un", [{ type: "underline" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("u")?.textContent).toBe("un");
|
||||||
|
// Underline is a supported mark, so no degrade to the editor fallback.
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the code mark as <code>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("co", [{ type: "code" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("code")?.textContent).toBe("co");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the link mark as an anchor with safe rel/target", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("click", [
|
||||||
|
{ type: "link", attrs: { href: "https://example.com" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a?.getAttribute("href")).toBe("https://example.com");
|
||||||
|
expect(a?.getAttribute("target")).toBe("_blank");
|
||||||
|
expect(a?.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||||
|
expect(a?.textContent).toBe("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a javascript: link href (stored XSS) while keeping the text", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("click", [
|
||||||
|
{ type: "link", attrs: { href: "javascript:alert(1)" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
// No navigable javascript: href — attribute is absent (or empty).
|
||||||
|
expect(a?.getAttribute("href")).toBeFalsy();
|
||||||
|
// The link text is still rendered.
|
||||||
|
expect(a?.textContent).toBe("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a control-char-obfuscated javascript: href", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("x", [
|
||||||
|
{ type: "link", attrs: { href: "java\tscript:alert(1)" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a data: link href", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("x", [
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
attrs: { href: "data:text/html,<script>alert(1)</script>" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a mailto: link href (allowlisted scheme)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("mail", [
|
||||||
|
{ type: "link", attrs: { href: "mailto:a@b.com" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||||
|
"mailto:a@b.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a relative link href (no scheme, not a script vector)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("rel", [{ type: "link", attrs: { href: "/some/path" } }]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||||
|
"/some/path",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests multiple marks on one text node", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("x", [{ type: "bold" }, { type: "italic" }])])]),
|
||||||
|
);
|
||||||
|
// bold wraps italic (or vice versa) — both elements exist around the text.
|
||||||
|
expect(container.querySelector("strong")).not.toBeNull();
|
||||||
|
expect(container.querySelector("em")).not.toBeNull();
|
||||||
|
expect(screen.getByText("x")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hardBreak as <br/>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("a"), { type: "hardBreak" }, text("b")])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("br")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a user mention as a styled span", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
{
|
||||||
|
type: "mention",
|
||||||
|
attrs: { label: "Alice", entityType: "user", entityId: "u1" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("@Alice")).toBeDefined();
|
||||||
|
// No fallback to the editor.
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a page mention as a link", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
{
|
||||||
|
type: "mention",
|
||||||
|
attrs: {
|
||||||
|
label: "Some Page",
|
||||||
|
entityType: "page",
|
||||||
|
slugId: "pg1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")).not.toBeNull();
|
||||||
|
expect(screen.getByText("Some Page")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a legacy plain-text (non-JSON) string as plain text", () => {
|
||||||
|
renderView("just a legacy string");
|
||||||
|
expect(screen.getByText("just a legacy string")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to CommentEditor for an unknown node type", () => {
|
||||||
|
renderView(doc([{ type: "codeBlock", content: [text("x")] }]));
|
||||||
|
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to CommentEditor for malformed JSON", () => {
|
||||||
|
renderView('{"type":"doc","content":[');
|
||||||
|
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React from "react";
|
||||||
|
import classes from "./comment.module.css";
|
||||||
|
import { MentionContent } from "@/features/editor/components/mention/mention-view";
|
||||||
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
|
||||||
|
// Static, editor-free renderer of a comment body (ProseMirror JSON). It walks the
|
||||||
|
// document and emits plain DOM, avoiding the cost of a full TipTap/ProseMirror
|
||||||
|
// instance per comment (the panel used to spin up 400+ editors on mount).
|
||||||
|
//
|
||||||
|
// The supported node/mark set MUST mirror what CommentEditor enables
|
||||||
|
// (StarterKit + Mention + LinkExtension). Anything outside that set makes the
|
||||||
|
// whole comment degrade to the read-only CommentEditor via the fallback below,
|
||||||
|
// so we never show a half-rendered comment.
|
||||||
|
|
||||||
|
// Sentinel thrown when we hit a node/mark we don't know how to render statically.
|
||||||
|
// Caught at the top level to trigger the CommentEditor fallback for the whole comment.
|
||||||
|
class UnknownNodeError extends Error {}
|
||||||
|
|
||||||
|
// Protocol allowlist mirroring @tiptap/extension-link's default (the read-only
|
||||||
|
// CommentEditor path relies on it to blank javascript:/data: hrefs). The static
|
||||||
|
// renderer must apply the SAME sanitization because the backend stores comment
|
||||||
|
// content verbatim and React does not neutralize javascript: in an href.
|
||||||
|
const ALLOWED_URI_SCHEMES = /^(?:https?|ftps?|mailto|tel|callto|sms|cid|xmpp):/i;
|
||||||
|
|
||||||
|
function safeHref(href: unknown): string | undefined {
|
||||||
|
if (typeof href !== "string") return undefined;
|
||||||
|
// Strip control chars/whitespace that could smuggle a scheme past the test
|
||||||
|
// (e.g. "java\tscript:").
|
||||||
|
const cleaned = href.replace(/[\u0000-\u0020]/g, "").trim();
|
||||||
|
// Allow relative/anchor/protocol-relative links (no scheme) — not script vectors.
|
||||||
|
if (!/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return href;
|
||||||
|
return ALLOWED_URI_SCHEMES.test(cleaned) ? href : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PMMark {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PMNode {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
content?: PMNode[];
|
||||||
|
text?: string;
|
||||||
|
marks?: PMMark[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap a text node's string in its marks (marks nest, e.g. bold + italic).
|
||||||
|
function renderMarks(
|
||||||
|
text: React.ReactNode,
|
||||||
|
marks: PMMark[] | undefined,
|
||||||
|
keyPrefix: string,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (!marks || marks.length === 0) return text;
|
||||||
|
|
||||||
|
return marks.reduce<React.ReactNode>((acc, mark, i) => {
|
||||||
|
const key = `${keyPrefix}-m${i}`;
|
||||||
|
switch (mark.type) {
|
||||||
|
case "bold":
|
||||||
|
return <strong key={key}>{acc}</strong>;
|
||||||
|
case "italic":
|
||||||
|
return <em key={key}>{acc}</em>;
|
||||||
|
case "strike":
|
||||||
|
return <s key={key}>{acc}</s>;
|
||||||
|
case "underline":
|
||||||
|
// StarterKit enables the Underline extension by default (Mod-u) and
|
||||||
|
// CommentEditor does not disable it, so real comments can carry this
|
||||||
|
// mark. Render it here rather than degrading the whole comment.
|
||||||
|
return <u key={key}>{acc}</u>;
|
||||||
|
case "code":
|
||||||
|
return <code key={key}>{acc}</code>;
|
||||||
|
case "link": {
|
||||||
|
// LinkExtension (TiptapLink) opens links in a new tab; keep the same
|
||||||
|
// safe rel semantics the editor produces. Sanitize the href against the
|
||||||
|
// extension's protocol allowlist — a disallowed scheme (javascript:,
|
||||||
|
// data:) yields undefined so the anchor is non-navigable but still shows
|
||||||
|
// its text, matching how extension-link blanks a bad href.
|
||||||
|
const href = safeHref(mark.attrs?.href);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
>
|
||||||
|
{acc}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new UnknownNodeError(`Unknown mark type: ${mark.type}`);
|
||||||
|
}
|
||||||
|
}, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node: PMNode, key: string): React.ReactNode {
|
||||||
|
switch (node.type) {
|
||||||
|
case "paragraph":
|
||||||
|
return <p key={key}>{renderChildren(node.content, key)}</p>;
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<React.Fragment key={key}>
|
||||||
|
{renderMarks(node.text ?? "", node.marks, key)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
case "hardBreak":
|
||||||
|
return <br key={key} />;
|
||||||
|
case "mention":
|
||||||
|
return (
|
||||||
|
<span key={key} style={{ display: "inline" }}>
|
||||||
|
<MentionContent attrs={node.attrs as any} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new UnknownNodeError(`Unknown node type: ${node.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChildren(
|
||||||
|
content: PMNode[] | undefined,
|
||||||
|
keyPrefix: string,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (!content) return null;
|
||||||
|
return content.map((child, i) => renderNode(child, `${keyPrefix}-${i}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reproduce the exact DOM nesting the read-only CommentEditor renders so the
|
||||||
|
// scoped CSS in comment.module.css (which targets
|
||||||
|
// `.commentEditor .ProseMirror :global(.ProseMirror)` and `.ProseMirror p`)
|
||||||
|
// applies pixel-for-pixel. Read-only => no data-editable / data-surface attrs.
|
||||||
|
function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={classes.commentEditor}>
|
||||||
|
<div className={classes.ProseMirror}>
|
||||||
|
<div className="ProseMirror">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentContentViewProps {
|
||||||
|
content: string | object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentContentView({ content }: CommentContentViewProps) {
|
||||||
|
// Degrade this single comment to the old editor-based render (safety valve).
|
||||||
|
const fallback = () => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn(
|
||||||
|
"CommentContentView: unsupported comment content, falling back to editor",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <CommentEditor defaultContent={content} editable={false} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
let doc: unknown = content;
|
||||||
|
|
||||||
|
if (typeof content === "string") {
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
// Looks like it was meant to be JSON but is malformed -> safety-valve fallback.
|
||||||
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
// Otherwise it's a legacy plain-text comment: render as a single paragraph.
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<p>{content}</p>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-stringified / legacy plain-text stored as a JSON string.
|
||||||
|
if (typeof doc === "string") {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<p>{doc}</p>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pmDoc = doc as PMNode;
|
||||||
|
if (!pmDoc || typeof pmDoc !== "object" || pmDoc.type !== "doc") {
|
||||||
|
throw new UnknownNodeError("Not a ProseMirror doc");
|
||||||
|
}
|
||||||
|
return <Shell>{renderChildren(pmDoc.content, "n")}</Shell>;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnknownNodeError) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommentContentView;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
@@ -8,23 +8,74 @@ import { IComment } from "@/features/comment/types/comment.types";
|
|||||||
// The comment mutation hooks reach out to react-query/network — stub them so the
|
// The comment mutation hooks reach out to react-query/network — stub them so the
|
||||||
// component renders in isolation. We only assert the AI-badge rendering branch.
|
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||||
const applyMutateAsync = vi.fn();
|
const applyMutateAsync = vi.fn();
|
||||||
|
const dismissMutateAsync = vi.fn();
|
||||||
|
const updateMutateAsync = vi.fn();
|
||||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useUpdateCommentMutation: () => ({ mutateAsync: updateMutateAsync }),
|
||||||
useApplySuggestionMutation: () => ({
|
useApplySuggestionMutation: () => ({
|
||||||
mutateAsync: applyMutateAsync,
|
mutateAsync: applyMutateAsync,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
}),
|
}),
|
||||||
|
useDismissSuggestionMutation: () => ({
|
||||||
|
mutateAsync: dismissMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The document the mocked editor emits via onUpdate when the edit form is open.
|
||||||
|
// Duplicated inside the mock factory (below) to keep the factory self-contained.
|
||||||
|
const EDITED_DOC = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
// In edit mode the stub exposes buttons that fire the real onUpdate/onSave props
|
||||||
default: () => <div data-testid="comment-editor" />,
|
// so the edit->save/cancel flow can be driven without a live editor.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
default: ({ onUpdate, onSave }: any) => (
|
||||||
|
<div data-testid="comment-editor">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="editor-emit-update"
|
||||||
|
onClick={() => onUpdate?.(doc)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="editor-emit-save"
|
||||||
|
onClick={() => onSave?.()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// CommentContentView (used for the read-only body) imports the mention view,
|
||||||
|
// which pulls page-query -> main.tsx (createRoot). Stub the queries so the item
|
||||||
|
// renders in isolation without the app entry side-effect.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import CommentListItem from "./comment-list-item";
|
import CommentListItem from "./comment-list-item";
|
||||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
import {
|
||||||
|
canShowApply,
|
||||||
|
canShowDismiss,
|
||||||
|
} from "@/features/comment/utils/suggestion";
|
||||||
|
|
||||||
const baseComment = (over?: Partial<IComment>): IComment =>
|
const baseComment = (over?: Partial<IComment>): IComment =>
|
||||||
({
|
({
|
||||||
@@ -38,14 +89,20 @@ const baseComment = (over?: Partial<IComment>): IComment =>
|
|||||||
...over,
|
...over,
|
||||||
}) as IComment;
|
}) as IComment;
|
||||||
|
|
||||||
function renderItem(comment: IComment, canEdit = true) {
|
function renderItem(
|
||||||
|
comment: IComment,
|
||||||
|
canEdit = true,
|
||||||
|
canComment = true,
|
||||||
|
userSpaceRole?: string,
|
||||||
|
) {
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<CommentListItem
|
<CommentListItem
|
||||||
comment={comment}
|
comment={comment}
|
||||||
pageId="page-1"
|
pageId="page-1"
|
||||||
canComment={true}
|
canComment={canComment}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
@@ -108,10 +165,12 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
||||||
renderItem(suggestion(), true);
|
const { container } = renderItem(suggestion(), true);
|
||||||
// Old text appears both as the selection quote and as the struck diff row.
|
// Old text appears as the selection quote (a single unsplit Text node).
|
||||||
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("new wording here")).toBeDefined();
|
// The new line is now rendered as per-fragment spans (intraline diff, #331),
|
||||||
|
// so it is no longer a single text node — assert the concatenated content.
|
||||||
|
expect(container.textContent).toContain("new wording here");
|
||||||
// Apply button is present.
|
// Apply button is present.
|
||||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
// No Applied badge yet.
|
// No Applied badge yet.
|
||||||
@@ -119,9 +178,9 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("hides the Apply button when canEdit is false", () => {
|
it("hides the Apply button when canEdit is false", () => {
|
||||||
renderItem(suggestion(), false);
|
const { container } = renderItem(suggestion(), false);
|
||||||
// Diff still renders...
|
// Diff still renders (as per-fragment spans, #331)...
|
||||||
expect(screen.getByText("new wording here")).toBeDefined();
|
expect(container.textContent).toContain("new wording here");
|
||||||
// ...but no Apply button.
|
// ...but no Apply button.
|
||||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -157,6 +216,65 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — dismiss suggestion (#329)", () => {
|
||||||
|
const suggestion = (over?: Partial<IComment>): IComment =>
|
||||||
|
baseComment({
|
||||||
|
selection: "old wording here",
|
||||||
|
suggestedText: "new wording here",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate
|
||||||
|
// regardless of who authored the comment; the tests below use it as the lever
|
||||||
|
// since the currentUser atom is unseeded (null) in this harness.
|
||||||
|
it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => {
|
||||||
|
renderItem(suggestion(), true, true, "admin");
|
||||||
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
|
||||||
|
renderItem(suggestion(), false, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss when the viewer cannot comment", () => {
|
||||||
|
renderItem(suggestion(), false, false, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => {
|
||||||
|
// canComment=true but NOT a space admin and NOT the comment owner (the
|
||||||
|
// currentUser atom is null while the comment is authored by user-1), so the
|
||||||
|
// server would 403 a dismiss — the button must not be shown at all.
|
||||||
|
renderItem(suggestion(), false, true, "member");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss once the thread is resolved", () => {
|
||||||
|
renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss (shows the Applied badge) once applied", () => {
|
||||||
|
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
expect(screen.getByText("Applied")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the dismiss mutation when the Dismiss button is clicked", () => {
|
||||||
|
dismissMutateAsync.mockClear();
|
||||||
|
renderItem(suggestion(), true, true, "admin");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||||
|
expect(dismissMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: "page-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("canShowApply predicate", () => {
|
describe("canShowApply predicate", () => {
|
||||||
const c = (over?: Partial<IComment>): IComment =>
|
const c = (over?: Partial<IComment>): IComment =>
|
||||||
({ suggestedText: "x", ...over }) as IComment;
|
({ suggestedText: "x", ...over }) as IComment;
|
||||||
@@ -182,3 +300,161 @@ describe("canShowApply predicate", () => {
|
|||||||
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("canShowDismiss predicate", () => {
|
||||||
|
const c = (over?: Partial<IComment>): IComment =>
|
||||||
|
({ suggestedText: "x", ...over }) as IComment;
|
||||||
|
|
||||||
|
it("true when suggestion present, can comment, owner/admin, not applied/resolved, top-level", () => {
|
||||||
|
expect(canShowDismiss(c(), true, true)).toBe(true);
|
||||||
|
});
|
||||||
|
it("false without comment permission", () => {
|
||||||
|
expect(canShowDismiss(c(), false, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when not owner and not admin (#338 F5)", () => {
|
||||||
|
expect(canShowDismiss(c(), true, false)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when no suggestion", () => {
|
||||||
|
expect(canShowDismiss(c({ suggestedText: null }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when already applied", () => {
|
||||||
|
expect(canShowDismiss(c({ suggestionAppliedAt: new Date() }), true, true)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("false when resolved", () => {
|
||||||
|
expect(canShowDismiss(c({ resolvedAt: new Date() }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false for a reply comment", () => {
|
||||||
|
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — edit -> save/cancel flow (#340 F3)", () => {
|
||||||
|
const body = (t: string) =>
|
||||||
|
JSON.stringify({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The edit menu item is gated on the viewer owning the comment
|
||||||
|
// (currentUser.id === creatorId). currentUserAtom is atomWithStorage-backed,
|
||||||
|
// so seed localStorage to make the viewer the owner (creatorId "user-1").
|
||||||
|
beforeEach(() => {
|
||||||
|
updateMutateAsync.mockClear();
|
||||||
|
localStorage.setItem(
|
||||||
|
"currentUser",
|
||||||
|
JSON.stringify({ user: { id: "user-1", name: "Owner" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openEditor() {
|
||||||
|
// Open the comment menu, then click "Edit comment" to toggle into edit mode.
|
||||||
|
fireEvent.click(screen.getByLabelText("Comment menu"));
|
||||||
|
fireEvent.click(await screen.findByText("Edit comment"));
|
||||||
|
// Edit form (mocked editor + actions) is now mounted.
|
||||||
|
await screen.findByTestId("comment-editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("saves the edited content and, on cache update, shows the new body", async () => {
|
||||||
|
const { rerender } = renderItem(
|
||||||
|
baseComment({ content: body("original body") }),
|
||||||
|
);
|
||||||
|
// Static body first.
|
||||||
|
expect(screen.getByText("original body")).toBeDefined();
|
||||||
|
|
||||||
|
await openEditor();
|
||||||
|
|
||||||
|
// Editor emits an update (populates editContentRef), then Save is clicked.
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
// mutateAsync is called with the stringified edited doc.
|
||||||
|
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
content: JSON.stringify(EDITED_DOC),
|
||||||
|
});
|
||||||
|
|
||||||
|
// On success the form closes (isEditing -> false); the static body renders
|
||||||
|
// from the comment.content prop again.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate the cache invalidation swapping in a new comment object with the
|
||||||
|
// updated content — the static body reflects it.
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<CommentListItem
|
||||||
|
comment={baseComment({ content: body("updated body after save") })}
|
||||||
|
pageId="page-1"
|
||||||
|
canComment={true}
|
||||||
|
canEdit={true}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("updated body after save")).toBeDefined();
|
||||||
|
expect(screen.queryByText("original body")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel restores the static body and does not call the update mutation", async () => {
|
||||||
|
renderItem(baseComment({ content: body("original body") }));
|
||||||
|
await openEditor();
|
||||||
|
|
||||||
|
// Type something (editContentRef set), then cancel.
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
// Editor unmounts, static body restored, no save happened.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("original body")).toBeDefined();
|
||||||
|
expect(updateMutateAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving without editing sends the existing content (editContentRef cleared after cancel)", async () => {
|
||||||
|
renderItem(baseComment({ content: body("original body") }));
|
||||||
|
|
||||||
|
// Cancel path clears editContentRef...
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...so re-opening and saving WITHOUT an update falls back to comment.content.
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
content: JSON.stringify(body("original body")),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — read-only body renders statically", () => {
|
||||||
|
it("renders the comment body as static text without a TipTap editor", () => {
|
||||||
|
renderItem(
|
||||||
|
baseComment({
|
||||||
|
content: JSON.stringify({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Hello static world" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Body text is present...
|
||||||
|
expect(screen.getByText("Hello static world")).toBeDefined();
|
||||||
|
// ...and it did NOT go through the (mocked) CommentEditor instance.
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
import CommentContentView from "@/features/comment/components/comment-content-view";
|
||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||||
@@ -13,11 +14,16 @@ import { useHover } from "@mantine/hooks";
|
|||||||
import {
|
import {
|
||||||
useApplySuggestionMutation,
|
useApplySuggestionMutation,
|
||||||
useDeleteCommentMutation,
|
useDeleteCommentMutation,
|
||||||
|
useDismissSuggestionMutation,
|
||||||
useResolveCommentMutation,
|
useResolveCommentMutation,
|
||||||
useUpdateCommentMutation,
|
useUpdateCommentMutation,
|
||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
import {
|
||||||
|
canShowApply,
|
||||||
|
canShowDismiss,
|
||||||
|
computeSuggestionDiff,
|
||||||
|
} from "@/features/comment/utils/suggestion";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -45,31 +51,43 @@ function CommentListItem({
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const editor = useAtomValue(pageEditorAtom);
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
const [content, setContent] = useState<string>(comment.content);
|
|
||||||
const editContentRef = useRef<any>(null);
|
const editContentRef = useRef<any>(null);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
const applySuggestionMutation = useApplySuggestionMutation();
|
const applySuggestionMutation = useApplySuggestionMutation();
|
||||||
|
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||||
|
|
||||||
useEffect(() => {
|
// Intraline "before -> after" diff (#331) for a suggested edit: only the
|
||||||
setContent(comment.content);
|
// fragments that actually changed get emphasised inside the red/green block,
|
||||||
}, [comment]);
|
// instead of striking through / greening the whole line. Memoised on the
|
||||||
|
// (selection, suggestedText) pair so it recomputes only when they change.
|
||||||
|
const suggestionDiff = useMemo(
|
||||||
|
() =>
|
||||||
|
comment.suggestedText != null
|
||||||
|
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
|
||||||
|
: null,
|
||||||
|
[comment.selection, comment.suggestedText],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
|
||||||
|
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
|
||||||
|
// render an action the server will 403.
|
||||||
|
const isOwnerOrAdmin =
|
||||||
|
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||||
|
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const commentToUpdate = {
|
const commentToUpdate = {
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
content: JSON.stringify(editContentRef.current ?? content),
|
content: JSON.stringify(editContentRef.current ?? comment.content),
|
||||||
};
|
};
|
||||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||||
if (editContentRef.current) {
|
editContentRef.current = null;
|
||||||
setContent(editContentRef.current);
|
|
||||||
editContentRef.current = null;
|
|
||||||
}
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update comment:", error);
|
console.error("Failed to update comment:", error);
|
||||||
@@ -115,6 +133,19 @@ function CommentListItem({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDismissSuggestion() {
|
||||||
|
try {
|
||||||
|
await dismissSuggestionMutation.mutateAsync({
|
||||||
|
commentId: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Idempotent races are reconciled to success in the mutation's onError;
|
||||||
|
// anything else surfaces there as a notification.
|
||||||
|
console.error("Failed to dismiss suggestion:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleCommentClick(comment: IComment) {
|
function handleCommentClick(comment: IComment) {
|
||||||
const el = document.querySelector(
|
const el = document.querySelector(
|
||||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||||
@@ -190,7 +221,7 @@ function CommentListItem({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
{isOwnerOrAdmin && (
|
||||||
<CommentMenu
|
<CommentMenu
|
||||||
onEditComment={handleEditToggle}
|
onEditComment={handleEditToggle}
|
||||||
onDeleteComment={handleDeleteComment}
|
onDeleteComment={handleDeleteComment}
|
||||||
@@ -236,12 +267,28 @@ function CommentListItem({
|
|||||||
{!comment.parentCommentId && comment.suggestedText && (
|
{!comment.parentCommentId && comment.suggestedText && (
|
||||||
<Box className={classes.suggestionBlock}>
|
<Box className={classes.suggestionBlock}>
|
||||||
{comment.selection && (
|
{comment.selection && (
|
||||||
|
// Old line: read as removed as a whole (line-through/red); only the
|
||||||
|
// changed fragments carry the extra intraline emphasis.
|
||||||
<Text size="xs" className={classes.suggestionOld}>
|
<Text size="xs" className={classes.suggestionOld}>
|
||||||
{comment.selection}
|
{suggestionDiff?.old.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text size="xs" className={classes.suggestionNew}>
|
<Text size="xs" className={classes.suggestionNew}>
|
||||||
{comment.suggestedText}
|
{suggestionDiff?.new.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{comment.suggestionAppliedAt ? (
|
{comment.suggestionAppliedAt ? (
|
||||||
@@ -255,29 +302,53 @@ function CommentListItem({
|
|||||||
{t("Applied")}
|
{t("Applied")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
canShowApply(comment, canEdit) && (
|
(canShowApply(comment, canEdit) ||
|
||||||
<Button
|
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
||||||
size="compact-xs"
|
<Group gap="xs" mt={6}>
|
||||||
variant="light"
|
{canShowApply(comment, canEdit) && (
|
||||||
color="green"
|
<Button
|
||||||
mt={6}
|
size="compact-xs"
|
||||||
onClick={handleApplySuggestion}
|
variant="light"
|
||||||
loading={applySuggestionMutation.isPending}
|
color="green"
|
||||||
disabled={applySuggestionMutation.isPending}
|
onClick={handleApplySuggestion}
|
||||||
>
|
loading={applySuggestionMutation.isPending}
|
||||||
{t("Apply")}
|
disabled={
|
||||||
</Button>
|
applySuggestionMutation.isPending ||
|
||||||
|
dismissSuggestionMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Apply")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Dismiss ("Не применять", #329): removes the suggestion
|
||||||
|
without changing the page text. Gated on canComment. */}
|
||||||
|
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={handleDismissSuggestion}
|
||||||
|
loading={dismissSuggestionMutation.isPending}
|
||||||
|
disabled={
|
||||||
|
applySuggestionMutation.isPending ||
|
||||||
|
dismissSuggestionMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Dismiss")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<CommentEditor defaultContent={content} editable={false} />
|
<CommentContentView content={comment.content} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
defaultContent={content}
|
defaultContent={comment.content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||||
onSave={handleUpdateComment}
|
onSave={handleUpdateComment}
|
||||||
@@ -297,4 +368,6 @@ function CommentListItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CommentListItem;
|
// Memoized so a resolve/apply/reply cache update (which only replaces the touched
|
||||||
|
// comment's object identity) re-renders that one thread, not all ~356 items.
|
||||||
|
export default React.memo(CommentListItem);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub so
|
||||||
|
// the lazy reply editor's mount transition can be observed without the editor.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||||
|
default: () => <div data-testid="comment-editor" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// page-query -> main.tsx (createRoot) is a module side effect; stub the queries
|
||||||
|
// pulled in transitively so importing the module is side-effect free.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
// space-query -> main.tsx (createRoot) is another module side effect; stub it.
|
||||||
|
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||||
|
useGetSpaceBySlugQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildChildrenByParent,
|
||||||
|
CommentEditorWithActions,
|
||||||
|
} from "./comment-list-with-tabs";
|
||||||
|
|
||||||
|
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
||||||
|
({ id, parentCommentId }) as IComment;
|
||||||
|
|
||||||
|
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||||
|
it("returns an empty map for undefined or empty input", () => {
|
||||||
|
expect(buildChildrenByParent(undefined).size).toBe(0);
|
||||||
|
expect(buildChildrenByParent([]).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not index a top-level comment (parentCommentId null)", () => {
|
||||||
|
const map = buildChildrenByParent([c("p1", null)]);
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
expect(map.has("p1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups replies under the correct parent, including reply-to-reply nesting", () => {
|
||||||
|
const p1 = c("p1", null);
|
||||||
|
const r1 = c("r1", "p1");
|
||||||
|
const r2 = c("r2", "r1"); // a reply to a reply
|
||||||
|
const map = buildChildrenByParent([p1, r1, r2]);
|
||||||
|
expect(map.get("p1")).toEqual([r1]);
|
||||||
|
expect(map.get("r1")).toEqual([r2]);
|
||||||
|
// The top-level comment itself is never a key.
|
||||||
|
expect(map.has("p1") && map.get("p1")?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still groups a reply whose parent is not present in items", () => {
|
||||||
|
const orphan = c("o1", "missing-parent");
|
||||||
|
const map = buildChildrenByParent([orphan]);
|
||||||
|
expect(map.get("missing-parent")).toEqual([orphan]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves insertion order among sibling replies", () => {
|
||||||
|
const map = buildChildrenByParent([
|
||||||
|
c("a", "p1"),
|
||||||
|
c("b", "p1"),
|
||||||
|
c("d", "p1"),
|
||||||
|
]);
|
||||||
|
expect(map.get("p1")?.map((x) => x.id)).toEqual(["a", "b", "d"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderReplyEditor() {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<CommentEditorWithActions commentId="c-1" onSave={vi.fn()} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CommentEditorWithActions — lazy reply editor activation", () => {
|
||||||
|
it("shows only the stub initially (no editor instance mounted)", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
expect(screen.getByRole("button")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the real editor when the stub is clicked and keeps it mounted", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
// The stub button is replaced by the editor subtree.
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the editor when the stub receives focus", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.focus(screen.getByRole("button"));
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the editor on Enter keydown of the stub", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,6 @@ import CommentActions from "@/features/comment/components/comment-actions";
|
|||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -36,6 +35,24 @@ interface CommentListWithTabsProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index replies by their parent id once (O(n)), instead of an O(n^2) filter per
|
||||||
|
// thread. Replies whose parent is not in `items` are still grouped under their
|
||||||
|
// parentCommentId (they simply won't be reached by the top-level walk).
|
||||||
|
// Exported for unit testing.
|
||||||
|
export function buildChildrenByParent(
|
||||||
|
items: IComment[] | undefined,
|
||||||
|
): Map<string, IComment[]> {
|
||||||
|
const m = new Map<string, IComment[]>();
|
||||||
|
for (const c of items ?? []) {
|
||||||
|
if (c.parentCommentId) {
|
||||||
|
const arr = m.get(c.parentCommentId);
|
||||||
|
if (arr) arr.push(c);
|
||||||
|
else m.set(c.parentCommentId, [c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
@@ -46,7 +63,9 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
isError,
|
isError,
|
||||||
} = useCommentsQuery({ pageId: page?.id });
|
} = useCommentsQuery({ pageId: page?.id });
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
// mutateAsync is a stable reference across renders; depend on it (not the
|
||||||
|
// mutation object) so the reply/comment callbacks stay stable.
|
||||||
|
const createCommentAsync = createCommentMutation.mutateAsync;
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const canEdit = page?.permissions?.canEdit ?? false;
|
const canEdit = page?.permissions?.canEdit ?? false;
|
||||||
@@ -75,13 +94,21 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
return { activeComments: active, resolvedComments: resolved };
|
return { activeComments: active, resolvedComments: resolved };
|
||||||
}, [comments]);
|
}, [comments]);
|
||||||
|
|
||||||
|
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
||||||
|
// The map ref changes on any comments update, so MemoizedChildComments re-runs
|
||||||
|
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
|
||||||
|
const childrenByParent = useMemo(
|
||||||
|
() => buildChildrenByParent(comments?.items),
|
||||||
|
[comments?.items],
|
||||||
|
);
|
||||||
|
|
||||||
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
||||||
|
|
||||||
const handleAddPageComment = useCallback(
|
const handleAddPageComment = useCallback(
|
||||||
async (_commentId: string, content: string) => {
|
async (_commentId: string, content: string) => {
|
||||||
try {
|
try {
|
||||||
setIsPageCommentLoading(true);
|
setIsPageCommentLoading(true);
|
||||||
const createdComment = await createCommentMutation.mutateAsync({
|
const createdComment = await createCommentAsync({
|
||||||
pageId: page?.id,
|
pageId: page?.id,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
});
|
});
|
||||||
@@ -100,27 +127,26 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
setIsPageCommentLoading(false);
|
setIsPageCommentLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddReply = useCallback(
|
const handleAddReply = useCallback(
|
||||||
async (commentId: string, content: string) => {
|
async (commentId: string, content: string) => {
|
||||||
|
// Pending state lives inside CommentEditorWithActions so sending a reply
|
||||||
|
// does not churn renderComments and re-render the whole list.
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
|
||||||
const commentData = {
|
const commentData = {
|
||||||
pageId: page?.id,
|
pageId: page?.id,
|
||||||
parentCommentId: commentId,
|
parentCommentId: commentId,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
};
|
};
|
||||||
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
await createCommentAsync(commentData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to post comment:", error);
|
console.error("Failed to post comment:", error);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderComments = useCallback(
|
const renderComments = useCallback(
|
||||||
@@ -143,7 +169,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
userSpaceRole={space?.membership?.role}
|
userSpaceRole={space?.membership?.role}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
childrenByParent={childrenByParent}
|
||||||
parentId={comment.id}
|
parentId={comment.id}
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
@@ -158,16 +184,15 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
<CommentEditorWithActions
|
<CommentEditorWithActions
|
||||||
commentId={comment.id}
|
commentId={comment.id}
|
||||||
onSave={handleAddReply}
|
onSave={handleAddReply}
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
comments,
|
childrenByParent,
|
||||||
handleAddReply,
|
handleAddReply,
|
||||||
isLoading,
|
page?.id,
|
||||||
space?.membership?.role,
|
space?.membership?.role,
|
||||||
canComment,
|
canComment,
|
||||||
canEdit,
|
canEdit,
|
||||||
@@ -203,6 +228,11 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="open"
|
defaultValue="open"
|
||||||
variant="default"
|
variant="default"
|
||||||
|
// Default to not mounting an inactive tab (the heavy Resolved list stays
|
||||||
|
// unmounted while Open is shown). The Open panel overrides this with its
|
||||||
|
// own keepMounted (below) so an in-progress reply/edit draft survives an
|
||||||
|
// Open -> Resolved -> Open switch.
|
||||||
|
keepMounted={false}
|
||||||
style={{
|
style={{
|
||||||
flex: "1 1 auto",
|
flex: "1 1 auto",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -261,7 +291,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
<div style={{ paddingBottom: "8px" }}>
|
<div style={{ paddingBottom: "8px" }}>
|
||||||
<Tabs.Panel value="open" pt="xs">
|
{/* keepMounted keeps the Open panel alive even while Resolved is
|
||||||
|
active, so a lazily-mounted reply editor's draft (and an
|
||||||
|
in-progress edit) is not discarded on tab switch. */}
|
||||||
|
<Tabs.Panel value="open" pt="xs" keepMounted>
|
||||||
{activeComments.length === 0 ? (
|
{activeComments.length === 0 ? (
|
||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
@@ -307,7 +340,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ChildCommentsProps {
|
interface ChildCommentsProps {
|
||||||
comments: IPagination<IComment>;
|
childrenByParent: Map<string, IComment[]>;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
@@ -315,24 +348,18 @@ interface ChildCommentsProps {
|
|||||||
userSpaceRole?: string;
|
userSpaceRole?: string;
|
||||||
}
|
}
|
||||||
const ChildComments = ({
|
const ChildComments = ({
|
||||||
comments,
|
childrenByParent,
|
||||||
parentId,
|
parentId,
|
||||||
pageId,
|
pageId,
|
||||||
canComment,
|
canComment,
|
||||||
canEdit,
|
canEdit,
|
||||||
userSpaceRole,
|
userSpaceRole,
|
||||||
}: ChildCommentsProps) => {
|
}: ChildCommentsProps) => {
|
||||||
const getChildComments = useCallback(
|
const children = childrenByParent.get(parentId) ?? [];
|
||||||
(parentId: string) =>
|
|
||||||
comments.items.filter(
|
|
||||||
(comment: IComment) => comment.parentCommentId === parentId,
|
|
||||||
),
|
|
||||||
[comments.items],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{getChildComments(parentId).map((childComment) => (
|
{children.map((childComment) => (
|
||||||
<div key={childComment.id}>
|
<div key={childComment.id}>
|
||||||
<CommentListItem
|
<CommentListItem
|
||||||
comment={childComment}
|
comment={childComment}
|
||||||
@@ -342,7 +369,7 @@ const ChildComments = ({
|
|||||||
userSpaceRole={userSpaceRole}
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
childrenByParent={childrenByParent}
|
||||||
parentId={childComment.id}
|
parentId={childComment.id}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
@@ -357,22 +384,61 @@ const ChildComments = ({
|
|||||||
|
|
||||||
const MemoizedChildComments = memo(ChildComments);
|
const MemoizedChildComments = memo(ChildComments);
|
||||||
|
|
||||||
const CommentEditorWithActions = ({
|
export const CommentEditorWithActions = ({
|
||||||
commentId,
|
commentId,
|
||||||
onSave,
|
onSave,
|
||||||
isLoading,
|
|
||||||
placeholder = undefined,
|
placeholder = undefined,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Lazily mount the TipTap reply editor: until the user interacts with the
|
||||||
|
// stub, no editor instance is created for this thread. Once mounted it stays
|
||||||
|
// mounted so the draft is preserved.
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
const { ref, focused } = useFocusWithin();
|
const { ref, focused } = useFocusWithin();
|
||||||
const commentEditorRef = useRef(null);
|
const commentEditorRef = useRef(null);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const activate = useCallback(() => setMounted(true), []);
|
||||||
onSave(commentId, content);
|
|
||||||
setContent("");
|
const handleSave = useCallback(async () => {
|
||||||
commentEditorRef.current?.clearContent();
|
try {
|
||||||
|
setIsSending(true);
|
||||||
|
await onSave(commentId, content);
|
||||||
|
setContent("");
|
||||||
|
commentEditorRef.current?.clearContent();
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
}, [commentId, content, onSave]);
|
}, [commentId, content, onSave]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={activate}
|
||||||
|
onFocus={activate}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
activate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "6px",
|
||||||
|
fontSize: "var(--mantine-font-size-sm)",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: "var(--mantine-color-placeholder)",
|
||||||
|
cursor: "text",
|
||||||
|
borderRadius: "var(--mantine-radius-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{placeholder || t("Reply...")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
@@ -381,8 +447,9 @@ const CommentEditorWithActions = ({
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
editable={true}
|
editable={true}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
{focused && <CommentActions onSave={handleSave} isLoading={isSending} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,6 +53,21 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Intraline diff (#331): the fragment that actually changed within the
|
||||||
|
red "before" / green "after" block. It inherits the surrounding red/green
|
||||||
|
framing and adds a stronger tint plus bold weight so the eye lands on the
|
||||||
|
changed letters/words (git/GitHub-style) rather than the whole line. The
|
||||||
|
container's line-through (old) / green (new) still marks the full line. */
|
||||||
|
.suggestionChanged {
|
||||||
|
/* Stronger tint of the surrounding red/green so the changed fragment pops
|
||||||
|
within the block. `currentColor` follows the parent's red (old) or green
|
||||||
|
(new) text colour. No `text-decoration` here on purpose: the old block's
|
||||||
|
inherited line-through must survive on the changed letters too. */
|
||||||
|
background: color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.commentEditor {
|
.commentEditor {
|
||||||
|
|
||||||
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
InfiniteData,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for the ephemeral-suggestion (#329) cache reconciliation in
|
||||||
|
* useApplySuggestionMutation / useDismissSuggestionMutation: the mutations act on
|
||||||
|
* the server `outcome` — 'deleted' drops the comment from the local list,
|
||||||
|
* 'resolved' relocates it (by stamping resolvedAt, which the tabs split on).
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
applySuggestion: vi.fn(),
|
||||||
|
dismissSuggestion: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
updateComment: vi.fn(),
|
||||||
|
deleteComment: vi.fn(),
|
||||||
|
resolveComment: vi.fn(),
|
||||||
|
getPageComments: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import {
|
||||||
|
applySuggestion,
|
||||||
|
dismissSuggestion,
|
||||||
|
} from "@/features/comment/services/comment-service";
|
||||||
|
import {
|
||||||
|
useApplySuggestionMutation,
|
||||||
|
useDismissSuggestionMutation,
|
||||||
|
RQ_KEY,
|
||||||
|
} from "@/features/comment/queries/comment-query";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
const PAGE_ID = "page-1";
|
||||||
|
|
||||||
|
function seededClient(comment: IComment) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
const seed: InfiniteData<any> = {
|
||||||
|
pageParams: [undefined],
|
||||||
|
pages: [{ items: [comment], meta: { hasNextPage: false, nextCursor: null } }],
|
||||||
|
};
|
||||||
|
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
return { queryClient, wrapper };
|
||||||
|
}
|
||||||
|
|
||||||
|
function items(queryClient: QueryClient): IComment[] {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
|
||||||
|
| InfiniteData<any>
|
||||||
|
| undefined;
|
||||||
|
return cache?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = (over?: Partial<IComment>): IComment =>
|
||||||
|
({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
content: "{}",
|
||||||
|
creatorId: "u-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
suggestedText: "new",
|
||||||
|
...over,
|
||||||
|
}) as IComment;
|
||||||
|
|
||||||
|
describe("useApplySuggestionMutation — outcome handling (#329)", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("outcome=deleted → removes the comment from the list", async () => {
|
||||||
|
vi.mocked(applySuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "deleted",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outcome=resolved → keeps the comment and stamps resolvedAt/applied fields", async () => {
|
||||||
|
const resolvedAt = new Date();
|
||||||
|
vi.mocked(applySuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "resolved",
|
||||||
|
resolvedAt,
|
||||||
|
resolvedById: "u-1",
|
||||||
|
resolvedBy: { id: "u-1", name: "A" },
|
||||||
|
suggestionAppliedAt: resolvedAt,
|
||||||
|
suggestionAppliedById: "u-1",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const list = items(queryClient);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||||
|
expect(list[0].suggestionAppliedAt).toBe(resolvedAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useDismissSuggestionMutation — outcome handling (#329)", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("outcome=deleted → removes the comment from the list", async () => {
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "deleted",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outcome=resolved → keeps the comment and stamps resolvedAt", async () => {
|
||||||
|
const resolvedAt = new Date();
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "resolved",
|
||||||
|
resolvedAt,
|
||||||
|
resolvedById: "u-1",
|
||||||
|
resolvedBy: { id: "u-1", name: "A" },
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const list = items(queryClient);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||||
|
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||||
|
response: { status: 404 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
// mutateAsync rejects even though onError reconciles the cache; swallow it.
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
// #338 F3: the idempotent race must still fire the SUCCESS toast, not just
|
||||||
|
// silently drop the comment.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion dismissed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismiss 400 (thread still alive) → NOT a success, comment kept, no green toast (#338 F2)", async () => {
|
||||||
|
// 400 means the thread is alive (already resolved / a reply raced in).
|
||||||
|
// Narrowed onError: only 404 is a success-noop; 400 must surface a real error
|
||||||
|
// and keep the comment in the cache.
|
||||||
|
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||||
|
response: { status: 400 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// Comment NOT dropped from the cache.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
// A real (red) error, never the success message.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ color: "red" }),
|
||||||
|
);
|
||||||
|
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion dismissed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("APPLY idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||||
|
// After #329 an applied reply-less suggestion is hard-deleted, so a racing
|
||||||
|
// second apply hits 404 — must reconcile to success like dismiss, not a red
|
||||||
|
// error (restores the #315 apply idempotency).
|
||||||
|
vi.mocked(applySuggestion).mockRejectedValue({
|
||||||
|
response: { status: 404 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
// #338 F3: the idempotent race must still fire the SUCCESS toast.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion applied",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("APPLY 400 (thread resolved, not applied) → NOT a success, comment kept, red error (#338 F2)", async () => {
|
||||||
|
// apply's only 400 is "Cannot apply … on a resolved comment thread" — the
|
||||||
|
// thread was resolved (often with discussion) but NOT applied. It must be a
|
||||||
|
// real error surfacing the server message, and must NOT drop the live thread.
|
||||||
|
vi.mocked(applySuggestion).mockRejectedValue({
|
||||||
|
response: {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// The live thread is NOT dropped from the cache.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
// Surfaces the server's specific message as a red error, never a success.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||||
|
color: "red",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion applied",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
applySuggestion,
|
applySuggestion,
|
||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
|
dismissSuggestion,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
resolveComment,
|
resolveComment,
|
||||||
updateComment,
|
updateComment,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
IResolveComment,
|
IResolveComment,
|
||||||
|
ISuggestionOutcome,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
@@ -51,7 +53,10 @@ export function useCommentsQuery(params: ICommentParams) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
isLoading: query.isLoading || query.hasNextPage,
|
// Paint the first page as soon as it arrives instead of blocking until every
|
||||||
|
// page has loaded; the background effect above keeps streaming the rest
|
||||||
|
// (tab counts grow as pages arrive).
|
||||||
|
isLoading: query.isLoading,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -177,40 +182,121 @@ function updateCommentInCache(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeCommentFromCache(
|
||||||
|
cache: InfiniteData<IPagination<IComment>>,
|
||||||
|
commentId: string,
|
||||||
|
): InfiniteData<IPagination<IComment>> {
|
||||||
|
return {
|
||||||
|
...cache,
|
||||||
|
pages: cache.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
items: page.items.filter((comment) => comment.id !== commentId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile the local comment cache with an ephemeral-suggestion outcome (#329)
|
||||||
|
// returned by apply/dismiss: 'deleted' → drop the comment (it disappeared);
|
||||||
|
// 'resolved' → the thread had replies and was resolved, so carry the resolved
|
||||||
|
// state through (which relocates it to the resolved tab).
|
||||||
|
function applySuggestionOutcomeToCache(
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
|
pageId: string,
|
||||||
|
commentId: string,
|
||||||
|
data: ISuggestionOutcome,
|
||||||
|
) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (!cache) return;
|
||||||
|
|
||||||
|
if (data.outcome === "deleted") {
|
||||||
|
queryClient.setQueryData(RQ_KEY(pageId), removeCommentFromCache(cache, commentId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'resolved' (or an older server that omits outcome): reflect the resolved
|
||||||
|
// state and the applied stamps (apply sets them; dismiss leaves them null).
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(pageId),
|
||||||
|
updateCommentInCache(cache, commentId, (comment) => ({
|
||||||
|
...comment,
|
||||||
|
suggestionAppliedAt: data.suggestionAppliedAt,
|
||||||
|
suggestionAppliedById: data.suggestionAppliedById,
|
||||||
|
resolvedAt: data.resolvedAt,
|
||||||
|
resolvedById: data.resolvedById,
|
||||||
|
resolvedBy: data.resolvedBy,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useApplySuggestionMutation() {
|
export function useApplySuggestionMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<IComment, any, { commentId: string; pageId: string }>({
|
return useMutation<
|
||||||
|
ISuggestionOutcome,
|
||||||
|
any,
|
||||||
|
{ commentId: string; pageId: string }
|
||||||
|
>({
|
||||||
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
||||||
// so we only mutate the cache once the server confirms.
|
// so we only mutate the cache once the server confirms.
|
||||||
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
const cache = queryClient.getQueryData(
|
// Ephemeral (#329): the server hard-deletes the applied suggestion when the
|
||||||
RQ_KEY(variables.pageId),
|
// thread has no replies ('deleted') or resolves it when it does ('resolved').
|
||||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
applySuggestionOutcomeToCache(
|
||||||
|
queryClient,
|
||||||
if (cache) {
|
variables.pageId,
|
||||||
queryClient.setQueryData(
|
variables.commentId,
|
||||||
RQ_KEY(variables.pageId),
|
data,
|
||||||
updateCommentInCache(cache, variables.commentId, (comment) => ({
|
);
|
||||||
...comment,
|
|
||||||
suggestionAppliedAt: data.suggestionAppliedAt,
|
|
||||||
suggestionAppliedById: data.suggestionAppliedById,
|
|
||||||
// The server auto-resolves the thread on apply — carry that through.
|
|
||||||
resolvedAt: data.resolvedAt,
|
|
||||||
resolvedById: data.resolvedById,
|
|
||||||
resolvedBy: data.resolvedBy,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.show({ message: t("Suggestion applied") });
|
notifications.show({ message: t("Suggestion applied") });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any, variables) => {
|
||||||
|
const status = err?.response?.status;
|
||||||
|
// Idempotent race (double-click, or apply↔dismiss): after #329 an applied
|
||||||
|
// reply-less suggestion is hard-deleted, so a second/racing apply hits 404
|
||||||
|
// (already gone). ONLY 404 is a real success-noop — drop it from the cache
|
||||||
|
// and report success, the user's intent is already satisfied (restores the
|
||||||
|
// #315 apply idempotency the ephemeral delete would otherwise break).
|
||||||
|
//
|
||||||
|
// 400 is NOT success (#338 F2): apply's only 400 is "Cannot apply … on a
|
||||||
|
// resolved comment thread" — the thread was resolved (often WITH a live
|
||||||
|
// discussion) but the edit was NOT applied. Treating it as "Suggestion
|
||||||
|
// applied" is a false success that also drops a live thread from the cache.
|
||||||
|
// The #315 idempotent repeat does NOT produce 400 (childless → 404;
|
||||||
|
// with-replies → 200), so we never lose idempotency by excluding it here.
|
||||||
|
if (status === 404) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
removeCommentFromCache(cache, variables.commentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifications.show({ message: t("Suggestion applied") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 400 => the thread was resolved and the edit could not be applied. Show a
|
||||||
|
// real error and KEEP the comment in the cache (it is still alive). Prefer
|
||||||
|
// the server's specific message when it carries one.
|
||||||
|
if (status === 400) {
|
||||||
|
const serverMsg = err?.response?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message:
|
||||||
|
typeof serverMsg === "string" && serverMsg.length > 0
|
||||||
|
? serverMsg
|
||||||
|
: t("Failed to apply suggestion"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 409 => the commented text changed since the suggestion was made. Surface
|
// 409 => the commented text changed since the suggestion was made. Surface
|
||||||
// a specific message (with the current text) rather than a generic error.
|
// a specific message (with the current text) rather than a generic error.
|
||||||
const status = err?.response?.status;
|
|
||||||
const currentText = err?.response?.data?.currentText;
|
const currentText = err?.response?.data?.currentText;
|
||||||
if (status === 409 && typeof currentText === "string") {
|
if (status === 409 && typeof currentText === "string") {
|
||||||
const shortText =
|
const shortText =
|
||||||
@@ -234,6 +320,58 @@ export function useApplySuggestionMutation() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDismissSuggestionMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
ISuggestionOutcome,
|
||||||
|
any,
|
||||||
|
{ commentId: string; pageId: string }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ commentId }) => dismissSuggestion(commentId),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Ephemeral (#329): dismiss hard-deletes the suggestion when the thread has
|
||||||
|
// no replies ('deleted') or resolves it when it does ('resolved').
|
||||||
|
applySuggestionOutcomeToCache(
|
||||||
|
queryClient,
|
||||||
|
variables.pageId,
|
||||||
|
variables.commentId,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifications.show({ message: t("Suggestion dismissed") });
|
||||||
|
},
|
||||||
|
onError: (err: any, variables) => {
|
||||||
|
// Idempotent race (double-click, or apply↔dismiss): the comment is already
|
||||||
|
// gone (404). ONLY 404 is a real success-noop — drop it from the cache and
|
||||||
|
// report success, the user's intent (make it disappear) is satisfied.
|
||||||
|
//
|
||||||
|
// 400 is NOT success (#338 F2): it means the thread is still ALIVE (already
|
||||||
|
// resolved, or a reply raced in), so treating it as "dismissed" would drop
|
||||||
|
// a live thread from the cache. Show a real error and keep the comment.
|
||||||
|
const status = err?.response?.status;
|
||||||
|
if (status === 404) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
removeCommentFromCache(cache, variables.commentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifications.show({ message: t("Suggestion dismissed") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to dismiss suggestion"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
export function useResolveCommentMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
IResolveComment,
|
IResolveComment,
|
||||||
|
ISuggestionOutcome,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
@@ -18,13 +19,24 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applySuggestion(commentId: string): Promise<IComment> {
|
export async function applySuggestion(
|
||||||
|
commentId: string,
|
||||||
|
): Promise<ISuggestionOutcome> {
|
||||||
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
||||||
// the 409 body (`{ message, currentText }`) off err.response.data.
|
// the 409 body (`{ message, currentText }`) off err.response.data.
|
||||||
const req = await api.post("/comments/apply-suggestion", { commentId });
|
const req = await api.post("/comments/apply-suggestion", { commentId });
|
||||||
return req.data.data ?? req.data;
|
return req.data.data ?? req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function dismissSuggestion(
|
||||||
|
commentId: string,
|
||||||
|
): Promise<ISuggestionOutcome> {
|
||||||
|
// Dismiss ("Не применять") a suggested edit (#329): the server hard-deletes
|
||||||
|
// the comment (or resolves it when it has replies) and returns the outcome.
|
||||||
|
const req = await api.post("/comments/dismiss-suggestion", { commentId });
|
||||||
|
return req.data.data ?? req.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateComment(
|
export async function updateComment(
|
||||||
data: Partial<IComment>,
|
data: Partial<IComment>,
|
||||||
): Promise<IComment> {
|
): Promise<IComment> {
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ export interface IResolveComment {
|
|||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Result of applying or dismissing an ephemeral suggested edit (#329). The
|
||||||
|
// server hard-deletes the comment (`deleted`) unless the thread has replies, in
|
||||||
|
// which case it is resolved (`resolved`). The returned comment fields carry the
|
||||||
|
// resolved-branch state; `outcome` tells the client which optimistic action to
|
||||||
|
// take (drop the comment vs. move it to the resolved tab).
|
||||||
|
export type ISuggestionOutcome = IComment & {
|
||||||
|
outcome?: "deleted" | "resolved";
|
||||||
|
};
|
||||||
|
|
||||||
export interface ICommentParams extends QueryParams {
|
export interface ICommentParams extends QueryParams {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computeSuggestionDiff, Segment } from "@/features/comment/utils/suggestion";
|
||||||
|
|
||||||
|
// Reconstruct the plain string from a segment stream — the diff must be
|
||||||
|
// lossless (concatenating every fragment yields the original input).
|
||||||
|
const join = (segments: Segment[]): string =>
|
||||||
|
segments.map((s) => s.text).join("");
|
||||||
|
|
||||||
|
// The subset of segments (in order) that the UI would emphasise.
|
||||||
|
const changed = (segments: Segment[]): string[] =>
|
||||||
|
segments.filter((s) => s.changed).map((s) => s.text);
|
||||||
|
|
||||||
|
// Find the segment that contains a substring, to assert its `changed` flag.
|
||||||
|
const segmentWith = (segments: Segment[], needle: string): Segment | undefined =>
|
||||||
|
segments.find((s) => s.text.includes(needle));
|
||||||
|
|
||||||
|
describe("computeSuggestionDiff", () => {
|
||||||
|
it("highlights only the single changed letter in a one-letter edit", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("заведем", "заведём");
|
||||||
|
|
||||||
|
// Lossless.
|
||||||
|
expect(join(old)).toBe("заведем");
|
||||||
|
expect(join(neu)).toBe("заведём");
|
||||||
|
|
||||||
|
// Old side: exactly the `е` is changed, the rest is common.
|
||||||
|
expect(changed(old)).toEqual(["е"]);
|
||||||
|
expect(old).toEqual([
|
||||||
|
{ text: "завед", changed: false },
|
||||||
|
{ text: "е", changed: true },
|
||||||
|
{ text: "м", changed: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// New side: exactly the `ё` is changed.
|
||||||
|
expect(changed(neu)).toEqual(["ё"]);
|
||||||
|
expect(neu).toEqual([
|
||||||
|
{ text: "завед", changed: false },
|
||||||
|
{ text: "ё", changed: true },
|
||||||
|
{ text: "м", changed: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the differing words changed but keeps the shared word common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff(
|
||||||
|
"привет мир",
|
||||||
|
"здравствуй мир",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lossless.
|
||||||
|
expect(join(old)).toBe("привет мир");
|
||||||
|
expect(join(neu)).toBe("здравствуй мир");
|
||||||
|
|
||||||
|
// The shared trailing word stays common on both sides (no per-letter noise
|
||||||
|
// leaking across the differing words into `мир`).
|
||||||
|
expect(segmentWith(old, "мир")?.changed).toBe(false);
|
||||||
|
expect(segmentWith(neu, "мир")?.changed).toBe(false);
|
||||||
|
|
||||||
|
// The differing words are emphasised somewhere on each side.
|
||||||
|
expect(changed(old).length).toBeGreaterThan(0);
|
||||||
|
expect(changed(neu).length).toBeGreaterThan(0);
|
||||||
|
expect(changed(old).join("")).toContain("п"); // from `привет`
|
||||||
|
expect(changed(neu).join("")).toContain("зд"); // from `здравствуй`
|
||||||
|
|
||||||
|
// No changed fragment on either side touches the word `мир`.
|
||||||
|
expect(changed(old).some((t) => t.includes("мир"))).toBe(false);
|
||||||
|
expect(changed(neu).some((t) => t.includes("мир"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a whole inserted word changed and leaves the old line common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("a c", "a b c");
|
||||||
|
|
||||||
|
expect(join(old)).toBe("a c");
|
||||||
|
expect(join(neu)).toBe("a b c");
|
||||||
|
|
||||||
|
// Old line has no changed fragment (nothing was removed).
|
||||||
|
expect(changed(old)).toEqual([]);
|
||||||
|
// The inserted word is the only changed fragment on the new side.
|
||||||
|
expect(neu).toContainEqual({ text: "b ", changed: true });
|
||||||
|
expect(changed(neu)).toEqual(["b "]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a whole deleted word changed and leaves the new line common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("a b c", "a c");
|
||||||
|
|
||||||
|
expect(join(old)).toBe("a b c");
|
||||||
|
expect(join(neu)).toBe("a c");
|
||||||
|
|
||||||
|
// The deleted word is the only changed fragment on the old side.
|
||||||
|
expect(old).toContainEqual({ text: "b ", changed: true });
|
||||||
|
expect(changed(old)).toEqual(["b "]);
|
||||||
|
// New line has no changed fragment (nothing was added).
|
||||||
|
expect(changed(neu)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks everything common for identical strings", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("hello", "hello");
|
||||||
|
|
||||||
|
expect(old).toEqual([{ text: "hello", changed: false }]);
|
||||||
|
expect(neu).toEqual([{ text: "hello", changed: false }]);
|
||||||
|
expect(changed(old)).toEqual([]);
|
||||||
|
expect(changed(neu)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { diffWordsWithSpace, diffChars } from "diff";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
||||||
@@ -12,3 +13,127 @@ export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
|
|||||||
!comment.parentCommentId,
|
!comment.parentCommentId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One contiguous run of text within a suggestion's "before" or "after" line.
|
||||||
|
// `changed` marks the fragment that actually differs from the other side, so
|
||||||
|
// the UI can emphasise only the intraline delta (git/GitHub-style) instead of
|
||||||
|
// the whole line.
|
||||||
|
export interface Segment {
|
||||||
|
text: string;
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pure "before -> after" intraline diff (#331): the old line split into
|
||||||
|
// common vs. removed-and-changed fragments, and the new line split into common
|
||||||
|
// vs. added-and-changed fragments. Concatenating each side's `text` reproduces
|
||||||
|
// the original strings.
|
||||||
|
export interface SuggestionDiff {
|
||||||
|
old: Segment[];
|
||||||
|
new: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push a segment, coalescing runs of the same `changed` flag on the same side
|
||||||
|
// so the render emits as few spans as possible and tests stay predictable.
|
||||||
|
function pushSegment(segments: Segment[], text: string, changed: boolean): void {
|
||||||
|
if (text === "") return;
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (last && last.changed === changed) {
|
||||||
|
last.text += text;
|
||||||
|
} else {
|
||||||
|
segments.push({ text, changed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute an intraline diff between the old `selection` and the new
|
||||||
|
// `suggestedText` of a suggestion. PURE — no React, no DOM, no I/O.
|
||||||
|
//
|
||||||
|
// Hybrid word + char algorithm (per #331):
|
||||||
|
// 1. `diffWordsWithSpace` yields word-granular parts [{value, added, removed}].
|
||||||
|
// 2. An ADJACENT removed+added pair (a word replacement) is refined with
|
||||||
|
// `diffChars`: shared characters stay common, differing characters are
|
||||||
|
// marked `changed` on their respective side. This is what keeps a
|
||||||
|
// one-letter edit (заведем -> заведём) from highlighting the whole word.
|
||||||
|
// 3. A lone `added` (insertion) or lone `removed` (deletion) marks the whole
|
||||||
|
// fragment `changed`.
|
||||||
|
// 4. An unchanged part is `common` on both sides.
|
||||||
|
//
|
||||||
|
// Rejected alternatives: pure `diffChars` is noisy on word swaps; pure
|
||||||
|
// `diffWordsWithSpace` highlights the whole word rather than the changed letter.
|
||||||
|
export function computeSuggestionDiff(
|
||||||
|
oldStr: string,
|
||||||
|
newStr: string,
|
||||||
|
): SuggestionDiff {
|
||||||
|
const oldSegments: Segment[] = [];
|
||||||
|
const newSegments: Segment[] = [];
|
||||||
|
|
||||||
|
const parts = diffWordsWithSpace(oldStr, newStr);
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const next = parts[i + 1];
|
||||||
|
|
||||||
|
// A word replacement: a removed part immediately followed by an added part
|
||||||
|
// (or the reverse). Refine it character-by-character so only the differing
|
||||||
|
// letters are highlighted while shared letters stay common.
|
||||||
|
const isReplacementPair =
|
||||||
|
next &&
|
||||||
|
((part.removed && next.added) || (part.added && next.removed));
|
||||||
|
|
||||||
|
if (isReplacementPair) {
|
||||||
|
const removedPart = part.removed ? part : next;
|
||||||
|
const addedPart = part.added ? part : next;
|
||||||
|
|
||||||
|
const charParts = diffChars(removedPart.value, addedPart.value);
|
||||||
|
for (const cp of charParts) {
|
||||||
|
if (cp.added) {
|
||||||
|
pushSegment(newSegments, cp.value, true);
|
||||||
|
} else if (cp.removed) {
|
||||||
|
pushSegment(oldSegments, cp.value, true);
|
||||||
|
} else {
|
||||||
|
// Shared character: common on both sides.
|
||||||
|
pushSegment(oldSegments, cp.value, false);
|
||||||
|
pushSegment(newSegments, cp.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i++; // consume the paired part as well
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.added) {
|
||||||
|
// Lone insertion: only present in the new line, wholly changed.
|
||||||
|
pushSegment(newSegments, part.value, true);
|
||||||
|
} else if (part.removed) {
|
||||||
|
// Lone deletion: only present in the old line, wholly changed.
|
||||||
|
pushSegment(oldSegments, part.value, true);
|
||||||
|
} else {
|
||||||
|
// Unchanged: common on both sides.
|
||||||
|
pushSegment(oldSegments, part.value, false);
|
||||||
|
pushSegment(newSegments, part.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { old: oldSegments, new: newSegments };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the suggested-edit (#329) "Не применять" (Dismiss) button should be
|
||||||
|
// shown. Dismiss does NOT change the page text (so it needs only canComment, not
|
||||||
|
// canEdit), BUT a childless dismiss IRREVERSIBLY hard-deletes the comment, so the
|
||||||
|
// server gates it on comment-owner-OR-space-admin (#338 F5). The button must
|
||||||
|
// mirror that authz or a non-owner non-admin sees a live Dismiss that always
|
||||||
|
// 403s → red error. Hence isOwnerOrAdmin is required IN ADDITION to canComment.
|
||||||
|
// Same not-applied/not-resolved/top-level conditions as Apply.
|
||||||
|
export function canShowDismiss(
|
||||||
|
comment: IComment,
|
||||||
|
canComment?: boolean,
|
||||||
|
isOwnerOrAdmin?: boolean,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
canComment &&
|
||||||
|
isOwnerOrAdmin &&
|
||||||
|
comment.suggestedText &&
|
||||||
|
!comment.suggestionAppliedAt &&
|
||||||
|
!comment.resolvedAt &&
|
||||||
|
!comment.parentCommentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,9 +11,19 @@ import {
|
|||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import classes from "./mention.module.css";
|
import classes from "./mention.module.css";
|
||||||
|
|
||||||
export default function MentionView(props: NodeViewProps) {
|
interface MentionAttrs {
|
||||||
const { node } = props;
|
label?: string;
|
||||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
slugId?: string;
|
||||||
|
anchorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presentational mention renderer (no NodeViewWrapper). Shared by the editor
|
||||||
|
// NodeView (MentionView) and the static comment renderer (CommentContentView)
|
||||||
|
// so mention click/nav/icon behavior stays identical outside of an editor.
|
||||||
|
export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
|
||||||
|
const { label, entityType, slugId, anchorId } = attrs;
|
||||||
const isPageMention = entityType === "page";
|
const isPageMention = entityType === "page";
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug, pageSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
@@ -56,7 +66,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
<>
|
||||||
{entityType === "user" && (
|
{entityType === "user" && (
|
||||||
<Text className={classes.userMention} component="span">
|
<Text className={classes.userMention} component="span">
|
||||||
@{label}
|
@{label}
|
||||||
@@ -139,6 +149,14 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
</span>
|
</span>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MentionView(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||||
|
<MentionContent attrs={props.node.attrs} />
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useAtom, useStore } from "jotai";
|
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { getSpaceUrl } from "@/lib/config.ts";
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
|
|
||||||
export type UseTreeMutation = {
|
export type UseTreeMutation = {
|
||||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||||
@@ -43,6 +44,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
const removePageMutation = useRemovePageMutation();
|
const removePageMutation = useRemovePageMutation();
|
||||||
const movePageMutation = useMovePageMutation();
|
const movePageMutation = useMovePageMutation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const setMobileSidebar = useSetAtom(mobileSidebarAtom);
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug, pageSlug } = useParams();
|
||||||
|
|
||||||
const handleMove = useCallback(
|
const handleMove = useCallback(
|
||||||
@@ -201,8 +203,23 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
createdPage.title,
|
createdPage.title,
|
||||||
);
|
);
|
||||||
navigate(pageUrl);
|
navigate(pageUrl);
|
||||||
|
// On mobile the create action is triggered from inside the off-canvas
|
||||||
|
// sidebar drawer (space sidebar "+", tree-row "add subpage"). Navigating
|
||||||
|
// alone leaves that drawer open on top of the freshly created page, so the
|
||||||
|
// editor stays hidden behind the tree. Close it here so the new page opens
|
||||||
|
// in the editor — mirrors the row-click drawer-close in space-tree-row.
|
||||||
|
// No-op on desktop, where the mobile drawer atom is already false.
|
||||||
|
setMobileSidebar(false);
|
||||||
},
|
},
|
||||||
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
[
|
||||||
|
spaceId,
|
||||||
|
createPageMutation,
|
||||||
|
setData,
|
||||||
|
store,
|
||||||
|
navigate,
|
||||||
|
spaceSlug,
|
||||||
|
setMobileSidebar,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRename = useCallback(
|
const handleRename = useCallback(
|
||||||
|
|||||||
@@ -13,5 +13,22 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
// Coverage gate (issue #324). v8 provider (not istanbul) so ESM barrels
|
||||||
|
// like `@docmost/editor-ext` are not re-parsed/instrumented. Thresholds are
|
||||||
|
// set a few points below the level measured on develop, scoped to the files
|
||||||
|
// the suite exercises (`all: false`) rather than the whole app, so the gate
|
||||||
|
// passes today but fails on a genuine coverage regression.
|
||||||
|
coverage: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text-summary', 'text'],
|
||||||
|
all: false,
|
||||||
|
thresholds: {
|
||||||
|
statements: 55,
|
||||||
|
branches: 53,
|
||||||
|
functions: 44,
|
||||||
|
lines: 55,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,3 +130,59 @@ describe('CollaborationHandler.applyCommentSuggestion', () => {
|
|||||||
expect(value).toBe(42);
|
expect(value).toBe(42);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CollaborationHandler.deleteCommentMark', () => {
|
||||||
|
it('strips the comment mark for the given commentId (ephemeral suggestion #329)', async () => {
|
||||||
|
const doc = buildDocWithComment('Hello world', 'c1');
|
||||||
|
const { hocuspocus, connection } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
||||||
|
|
||||||
|
// The mark is gone; the text itself stays (deleting the anchor, not the run).
|
||||||
|
const xmlText = (
|
||||||
|
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
||||||
|
).get(0) as Y.XmlText;
|
||||||
|
expect(xmlText.toDelta()).toEqual([{ insert: 'Hello world' }]);
|
||||||
|
expect(connection.transact).toHaveBeenCalledTimes(1);
|
||||||
|
expect(connection.disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes the removal through removeYjsMarkByAttribute with the right args', async () => {
|
||||||
|
const doc = buildDocWithComment('abc', 'c9');
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const spy = jest.spyOn(yjsUtil, 'removeYjsMarkByAttribute');
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c9', user });
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
doc.getXmlFragment('default'),
|
||||||
|
'comment',
|
||||||
|
'commentId',
|
||||||
|
'c9',
|
||||||
|
);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a different comment\'s mark intact', async () => {
|
||||||
|
const doc = buildDocWithComment('keep me', 'other');
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
||||||
|
|
||||||
|
const xmlText = (
|
||||||
|
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
||||||
|
).get(0) as Y.XmlText;
|
||||||
|
expect(xmlText.toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'keep me',
|
||||||
|
attributes: { comment: { commentId: 'other', resolved: false } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
tiptapExtensions,
|
tiptapExtensions,
|
||||||
} from './collaboration.util';
|
} from './collaboration.util';
|
||||||
import {
|
import {
|
||||||
|
removeYjsMarkByAttribute,
|
||||||
replaceYjsMarkedText,
|
replaceYjsMarkedText,
|
||||||
setYjsMark,
|
setYjsMark,
|
||||||
updateYjsMarkAttribute,
|
updateYjsMarkAttribute,
|
||||||
@@ -78,6 +79,40 @@ export class CollaborationHandler {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
deleteCommentMark: async (
|
||||||
|
documentName: string,
|
||||||
|
payload: {
|
||||||
|
commentId: string;
|
||||||
|
user: User;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { commentId, user } = payload;
|
||||||
|
// Ephemeral suggestions (#329): when a suggestion-edit is dismissed or an
|
||||||
|
// applied one has no replies, the comment is hard-deleted and its inline
|
||||||
|
// anchor must vanish too. Mirror resolveCommentMark exactly, but instead
|
||||||
|
// of flipping the mark's `resolved` attribute we STRIP the `comment` mark
|
||||||
|
// entirely via removeYjsMarkByAttribute so no orphan highlight remains in
|
||||||
|
// the collaborative document.
|
||||||
|
//
|
||||||
|
// Routing this through collaboration.gateway's handleYjsEvent means the
|
||||||
|
// COLLAB_DISABLE_REDIS path invokes this handler directly (never a silent
|
||||||
|
// no-op) and a missing live instance is a hard error — the same guarantee
|
||||||
|
// applyCommentSuggestion/resolveCommentMark rely on.
|
||||||
|
await this.withYdocConnection(
|
||||||
|
hocuspocus,
|
||||||
|
documentName,
|
||||||
|
{ user },
|
||||||
|
(doc) => {
|
||||||
|
const fragment = doc.getXmlFragment('default');
|
||||||
|
removeYjsMarkByAttribute(
|
||||||
|
fragment,
|
||||||
|
'comment',
|
||||||
|
'commentId',
|
||||||
|
commentId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
applyCommentSuggestion: async (
|
applyCommentSuggestion: async (
|
||||||
documentName: string,
|
documentName: string,
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const AuditEvent = {
|
|||||||
COMMENT_RESOLVED: 'comment.resolved',
|
COMMENT_RESOLVED: 'comment.resolved',
|
||||||
COMMENT_REOPENED: 'comment.reopened',
|
COMMENT_REOPENED: 'comment.reopened',
|
||||||
COMMENT_SUGGESTION_APPLIED: 'comment.suggestion_applied',
|
COMMENT_SUGGESTION_APPLIED: 'comment.suggestion_applied',
|
||||||
|
COMMENT_SUGGESTION_DISMISSED: 'comment.suggestion_dismissed',
|
||||||
|
|
||||||
// Page
|
// Page
|
||||||
PAGE_CREATED: 'page.created',
|
PAGE_CREATED: 'page.created',
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { buildSystemPrompt, buildMcpToolingBlock } from './ai-chat.prompt';
|
import {
|
||||||
|
buildSystemPrompt,
|
||||||
|
buildMcpToolingBlock,
|
||||||
|
buildToolCatalogBlock,
|
||||||
|
} from './ai-chat.prompt';
|
||||||
import { Workspace } from '@docmost/db/types/entity.types';
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -396,3 +400,62 @@ describe('buildSystemPrompt page-changed note (#274)', () => {
|
|||||||
expect(opens).toBe(1);
|
expect(opens).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #332 deferred tool loading — the <tool_catalog> block builder and its
|
||||||
|
* gating inside buildSystemPrompt.
|
||||||
|
*/
|
||||||
|
describe('buildToolCatalogBlock (#332)', () => {
|
||||||
|
const catalog = [
|
||||||
|
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||||
|
{ name: 'transformPage', catalogLine: 'transformPage — run a JS transform.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('renders nothing when the feature is disabled', () => {
|
||||||
|
expect(buildToolCatalogBlock(catalog, false)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when the catalog is empty', () => {
|
||||||
|
expect(buildToolCatalogBlock([], true)).toBe('');
|
||||||
|
expect(buildToolCatalogBlock(undefined, true)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the verbatim header + each deferred catalogLine when enabled', () => {
|
||||||
|
const block = buildToolCatalogBlock(catalog, true);
|
||||||
|
expect(block).toContain('<tool_catalog note="deferred tools;');
|
||||||
|
expect(block).toContain('NEVER tell the user you lack a capability');
|
||||||
|
expect(block).toContain('Deferred tools (name — purpose):');
|
||||||
|
expect(block).toContain('- createPage — create a new page.');
|
||||||
|
expect(block).toContain('- transformPage — run a JS transform.');
|
||||||
|
expect(block).toContain('</tool_catalog>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSystemPrompt <tool_catalog> gating (#332)', () => {
|
||||||
|
const workspace = { name: 'Acme' } as unknown as Workspace;
|
||||||
|
const catalog = [
|
||||||
|
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('omits the catalog when the toggle is off (unchanged behavior)', () => {
|
||||||
|
const prompt = buildSystemPrompt({
|
||||||
|
workspace,
|
||||||
|
deferredToolsEnabled: false,
|
||||||
|
toolCatalog: catalog,
|
||||||
|
});
|
||||||
|
expect(prompt).not.toContain('<tool_catalog');
|
||||||
|
expect(prompt).not.toContain('createPage — create a new page.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the catalog (deferred lines only) when enabled', () => {
|
||||||
|
const prompt = buildSystemPrompt({
|
||||||
|
workspace,
|
||||||
|
deferredToolsEnabled: true,
|
||||||
|
toolCatalog: catalog,
|
||||||
|
});
|
||||||
|
expect(prompt).toContain('<tool_catalog');
|
||||||
|
expect(prompt).toContain('createPage — create a new page.');
|
||||||
|
// A core tool line is never in the catalog (the caller passes deferred only).
|
||||||
|
expect(prompt).not.toContain('searchPages —');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Workspace } from '@docmost/db/types/entity.types';
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
|
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
|
||||||
|
import type { ToolCatalogEntry } from './tools/tool-tiers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default agent persona used when the admin has not configured a custom system
|
* Default agent persona used when the admin has not configured a custom system
|
||||||
@@ -183,6 +184,55 @@ export interface BuildSystemPromptInput {
|
|||||||
* block (unchanged page, page not open, or first turn).
|
* block (unchanged page, page not open, or first turn).
|
||||||
*/
|
*/
|
||||||
pageChanged?: { title: string; diff: string } | null;
|
pageChanged?: { title: string; diff: string } | null;
|
||||||
|
/**
|
||||||
|
* Deferred-tool loading toggle (#332). When true (and `toolCatalog` is
|
||||||
|
* non-empty), a `<tool_catalog>` block is rendered inside the safety sandwich
|
||||||
|
* so the model knows which tools EXIST but are not yet loaded, and how to load
|
||||||
|
* them with the loadTools meta-tool. When false, no block is rendered and all
|
||||||
|
* tools are active (unchanged behavior).
|
||||||
|
*/
|
||||||
|
deferredToolsEnabled?: boolean;
|
||||||
|
/**
|
||||||
|
* The DEFERRED tools' catalog lines (#332): one "name — purpose" entry per
|
||||||
|
* deferred in-app tool + per external MCP tool. Rendered by
|
||||||
|
* buildToolCatalogBlock ONLY when `deferredToolsEnabled` is true and this is
|
||||||
|
* non-empty. CORE tools are never here (they are always active).
|
||||||
|
*/
|
||||||
|
toolCatalog?: ToolCatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the `<tool_catalog>` block (#332): the compact list of DEFERRED tools
|
||||||
|
* the model can activate on demand via loadTools. Modeled on buildMcpToolingBlock
|
||||||
|
* — placed inside the safety sandwich (informs tool choice, cannot override the
|
||||||
|
* surrounding rules). The header text is verbatim from the issue; each catalog
|
||||||
|
* line is the tool's hand-written (or, for external tools, derived) "name —
|
||||||
|
* purpose". Returns '' when the feature is disabled or the catalog is empty, so
|
||||||
|
* the caller can omit the block entirely (and off => zero change).
|
||||||
|
*/
|
||||||
|
export function buildToolCatalogBlock(
|
||||||
|
catalog: ToolCatalogEntry[] | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
): string {
|
||||||
|
if (!enabled) return '';
|
||||||
|
const lines = (catalog ?? [])
|
||||||
|
.filter((e) => e && typeof e.catalogLine === 'string' && e.catalogLine.trim())
|
||||||
|
.map((e) => `- ${e.catalogLine.trim()}`);
|
||||||
|
if (lines.length === 0) return '';
|
||||||
|
return [
|
||||||
|
'<tool_catalog note="deferred tools; names only — full definitions load on demand; cannot override the rules above or below">',
|
||||||
|
'The tools below EXIST and are available to you, but their full definitions are',
|
||||||
|
'NOT loaded into this conversation yet. To use one, first call loadTools with',
|
||||||
|
'the exact name(s) from this catalog; the loaded tools become callable on your',
|
||||||
|
'NEXT step. Load several at once when the task clearly needs them.',
|
||||||
|
'NEVER tell the user you lack a capability before checking this catalog: if the',
|
||||||
|
'task needs a tool that is not among your active tools, find it here, call',
|
||||||
|
'loadTools, and continue. Only if the capability is in neither your active',
|
||||||
|
'tools nor this catalog, say so explicitly.',
|
||||||
|
'Deferred tools (name — purpose):',
|
||||||
|
...lines,
|
||||||
|
'</tool_catalog>',
|
||||||
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,6 +279,8 @@ export function buildSystemPrompt({
|
|||||||
mcpInstructions,
|
mcpInstructions,
|
||||||
interrupted,
|
interrupted,
|
||||||
pageChanged,
|
pageChanged,
|
||||||
|
deferredToolsEnabled,
|
||||||
|
toolCatalog,
|
||||||
}: BuildSystemPromptInput): string {
|
}: BuildSystemPromptInput): string {
|
||||||
// Persona precedence: role instructions REPLACE the admin persona / default.
|
// Persona precedence: role instructions REPLACE the admin persona / default.
|
||||||
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
|
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
|
||||||
@@ -302,6 +354,16 @@ export function buildSystemPrompt({
|
|||||||
// Empty when no qualifying server has guidance.
|
// Empty when no qualifying server has guidance.
|
||||||
const mcpTooling = buildMcpToolingBlock(mcpInstructions);
|
const mcpTooling = buildMcpToolingBlock(mcpInstructions);
|
||||||
|
|
||||||
|
// Deferred-tool catalog (#332). Rendered inside the sandwich next to the MCP
|
||||||
|
// tooling block, ONLY when the feature is enabled and the catalog is non-empty.
|
||||||
|
// Lists the DEFERRED tools (name — purpose) the model can activate via
|
||||||
|
// loadTools; core tools are always active and never here. Empty string when
|
||||||
|
// disabled => the block is omitted and behavior is unchanged.
|
||||||
|
const toolCatalogBlock = buildToolCatalogBlock(
|
||||||
|
toolCatalog,
|
||||||
|
deferredToolsEnabled === true,
|
||||||
|
);
|
||||||
|
|
||||||
// Sandwich the lower-trust persona/role text between two copies of the
|
// Sandwich the lower-trust persona/role text between two copies of the
|
||||||
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
|
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
|
||||||
// and followed by the safety rules. The persona is delimited with explicit
|
// and followed by the safety rules. The persona is delimited with explicit
|
||||||
@@ -316,6 +378,7 @@ export function buildSystemPrompt({
|
|||||||
'</role_persona>',
|
'</role_persona>',
|
||||||
context,
|
context,
|
||||||
mcpTooling,
|
mcpTooling,
|
||||||
|
toolCatalogBlock,
|
||||||
SAFETY_FRAMEWORK,
|
SAFETY_FRAMEWORK,
|
||||||
]
|
]
|
||||||
.filter((part) => part !== '')
|
.filter((part) => part !== '')
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe('AiChatService.resolveRoleForRequest', () => {
|
|||||||
aiAgentRoleRepo as never,
|
aiAgentRoleRepo as never,
|
||||||
{} as never, // pageRepo
|
{} as never, // pageRepo
|
||||||
{} as never, // pageAccess
|
{} as never, // pageAccess
|
||||||
|
{} as never, // environment
|
||||||
);
|
);
|
||||||
return { service, aiChatRepo, aiAgentRoleRepo };
|
return { service, aiChatRepo, aiAgentRoleRepo };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe('AiChatService.onModuleInit (startup sweep)', () => {
|
|||||||
{} as never, // aiAgentRoleRepo
|
{} as never, // aiAgentRoleRepo
|
||||||
{} as never, // pageRepo
|
{} as never, // pageRepo
|
||||||
{} as never, // pageAccess
|
{} as never, // pageAccess
|
||||||
|
{} as never, // environment
|
||||||
);
|
);
|
||||||
return { service, aiChatMessageRepo };
|
return { service, aiChatMessageRepo };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,23 +217,78 @@ describe('rowToUiMessage', () => {
|
|||||||
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
|
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
|
||||||
* appended onto — not replacing — the original system prompt.
|
* appended onto — not replacing — the original system prompt.
|
||||||
*/
|
*/
|
||||||
|
// Narrowing helpers for the prepareAgentStep union return type.
|
||||||
|
const asLockdown = (r: ReturnType<typeof prepareAgentStep>) =>
|
||||||
|
r as { toolChoice: 'none'; system: string };
|
||||||
|
const asActive = (r: ReturnType<typeof prepareAgentStep>) =>
|
||||||
|
r as { activeTools: string[] };
|
||||||
|
|
||||||
describe('prepareAgentStep', () => {
|
describe('prepareAgentStep', () => {
|
||||||
it('returns undefined for the first step', () => {
|
// --- toggle OFF (default): unchanged behavior ---
|
||||||
|
it('returns undefined for the first step (toggle off)', () => {
|
||||||
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
|
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for a non-final step (just before the last)', () => {
|
it('returns undefined for a non-final step (toggle off)', () => {
|
||||||
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
|
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forces a text-only synthesis on the final allowed step', () => {
|
it('forces a text-only synthesis on the final allowed step (toggle off)', () => {
|
||||||
const result = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS');
|
const result = asLockdown(prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS'));
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result?.toolChoice).toBe('none');
|
expect(result.toolChoice).toBe('none');
|
||||||
// The original persona is preserved (prefix), not replaced.
|
// The original persona is preserved (prefix), not replaced.
|
||||||
expect(result?.system.startsWith('SYS')).toBe(true);
|
expect(result.system.startsWith('SYS')).toBe(true);
|
||||||
// The synthesis instruction is appended.
|
// The synthesis instruction is appended.
|
||||||
expect(result?.system).toContain(FINAL_STEP_INSTRUCTION);
|
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT narrow activeTools when the toggle is off', () => {
|
||||||
|
const result = prepareAgentStep(0, 'SYS', new Set(['createPage']), false);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- toggle ON (#332): deferred tool visibility ---
|
||||||
|
it('a non-final step exposes CORE + loadTools + activatedTools', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
const result = asActive(prepareAgentStep(0, 'SYS', activated, true));
|
||||||
|
expect(result.activeTools).toContain('searchPages'); // core
|
||||||
|
expect(result.activeTools).toContain('searchInPage'); // #330, core
|
||||||
|
expect(result.activeTools).toContain('editPageText'); // core
|
||||||
|
expect(result.activeTools).toContain('loadTools'); // meta-tool
|
||||||
|
// No deferred tool is active before it is loaded.
|
||||||
|
expect(result.activeTools).not.toContain('createPage');
|
||||||
|
expect(result.activeTools).not.toContain('transformPage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adding a name to activatedTools makes it appear on the next step', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
// Before loading: createPage is not active.
|
||||||
|
expect(
|
||||||
|
asActive(prepareAgentStep(1, 'SYS', activated, true)).activeTools,
|
||||||
|
).not.toContain('createPage');
|
||||||
|
// loadTools grows the SAME set…
|
||||||
|
activated.add('createPage');
|
||||||
|
// …so the next step sees it.
|
||||||
|
const next = asActive(prepareAgentStep(2, 'SYS', activated, true));
|
||||||
|
expect(next.activeTools).toContain('createPage');
|
||||||
|
expect(next.activeTools).toContain('loadTools');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an array for activatedTools too', () => {
|
||||||
|
const result = asActive(prepareAgentStep(0, 'SYS', ['transformPage'], true));
|
||||||
|
expect(result.activeTools).toContain('transformPage');
|
||||||
|
expect(result.activeTools).toContain('loadTools');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('final-step lockdown WINS even when the toggle is on', () => {
|
||||||
|
const result = asLockdown(
|
||||||
|
prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS', new Set(['createPage']), true),
|
||||||
|
);
|
||||||
|
// The lockdown shape (toolChoice none + synthesis) — not the activeTools shape.
|
||||||
|
expect(result.toolChoice).toBe('none');
|
||||||
|
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||||
|
expect((result as unknown as { activeTools?: string[] }).activeTools).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,15 @@ import {
|
|||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||||
import { McpClientsService } from './external-mcp/mcp-clients.service';
|
import { McpClientsService } from './external-mcp/mcp-clients.service';
|
||||||
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
import { buildSystemPrompt } from './ai-chat.prompt';
|
import { buildSystemPrompt } from './ai-chat.prompt';
|
||||||
|
import {
|
||||||
|
CORE_TOOL_KEYS,
|
||||||
|
CORE_TOOL_SET,
|
||||||
|
LOAD_TOOLS_NAME,
|
||||||
|
makeLoadToolsTool,
|
||||||
|
buildExternalToolCatalog,
|
||||||
|
} from './tools/tool-tiers';
|
||||||
import { computePageChange } from './page-change/page-change.util';
|
import { computePageChange } from './page-change/page-change.util';
|
||||||
import { roleModelOverride } from './roles/role-model-config';
|
import { roleModelOverride } from './roles/role-model-config';
|
||||||
import {
|
import {
|
||||||
@@ -54,24 +62,52 @@ const FINAL_STEP_INSTRUCTION =
|
|||||||
'language. If the information is incomplete, say so explicitly: summarize ' +
|
'language. If the information is incomplete, say so explicitly: summarize ' +
|
||||||
'what you found, what is still missing, and give your best partial conclusion.';
|
'what you found, what is still missing, and give your best partial conclusion.';
|
||||||
|
|
||||||
// Pure, unit-testable: decide per-step overrides. Returns undefined for normal
|
// Pure, unit-testable: decide per-step overrides. Two responsibilities:
|
||||||
// steps; on the final allowed step forces a text-only synthesis answer.
|
// 1. Final-step lockdown (always): on the final allowed step force a text-only
|
||||||
|
// synthesis answer (toolChoice 'none' + FINAL_STEP_INSTRUCTION). This WINS —
|
||||||
|
// it takes precedence over the deferred-tool narrowing below.
|
||||||
|
// 2. Deferred tool visibility (#332): when `deferredEnabled` and NOT the final
|
||||||
|
// step, expose only the CORE tools + loadTools + whatever loadTools has
|
||||||
|
// activated so far this turn (`activatedTools`), via `activeTools`. Deferred
|
||||||
|
// tools stay in the <tool_catalog> until the model loads them.
|
||||||
|
// When `deferredEnabled` is false the behavior is unchanged: undefined on normal
|
||||||
|
// steps (all tools active), lockdown on the final step.
|
||||||
|
//
|
||||||
// `system` is the in-scope system prompt; we CONCATENATE so the original
|
// `system` is the in-scope system prompt; we CONCATENATE so the original
|
||||||
// persona/context is preserved — a bare `system` override would REPLACE the
|
// persona/context is preserved — a bare `system` override would REPLACE the
|
||||||
// whole system prompt for the step.
|
// whole system prompt for the step. `activatedTools` is PER-TURN mutable state
|
||||||
|
// owned by the streaming loop (a closure Set grown by loadTools); it is passed
|
||||||
|
// in (not module-global, not persisted) so this stays a pure function of its
|
||||||
|
// arguments.
|
||||||
//
|
//
|
||||||
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
|
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
|
||||||
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
|
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
|
||||||
export function prepareAgentStep(
|
export function prepareAgentStep(
|
||||||
stepNumber: number,
|
stepNumber: number,
|
||||||
system: string,
|
system: string,
|
||||||
): { toolChoice: 'none'; system: string } | undefined {
|
activatedTools: ReadonlySet<string> | readonly string[] = [],
|
||||||
|
deferredEnabled = false,
|
||||||
|
):
|
||||||
|
| { toolChoice: 'none'; system: string }
|
||||||
|
| { activeTools: string[] }
|
||||||
|
| undefined {
|
||||||
|
// Final-step lockdown WINS (applies regardless of the deferred toggle).
|
||||||
if (stepNumber >= MAX_AGENT_STEPS - 1) {
|
if (stepNumber >= MAX_AGENT_STEPS - 1) {
|
||||||
return {
|
return {
|
||||||
toolChoice: 'none',
|
toolChoice: 'none',
|
||||||
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
|
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Deferred tool loading: narrow this step's visible tools to CORE + loadTools
|
||||||
|
// + the tools already activated this turn.
|
||||||
|
if (deferredEnabled) {
|
||||||
|
const activated = Array.isArray(activatedTools)
|
||||||
|
? activatedTools
|
||||||
|
: [...activatedTools];
|
||||||
|
return {
|
||||||
|
activeTools: [...CORE_TOOL_KEYS, LOAD_TOOLS_NAME, ...activated],
|
||||||
|
};
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +242,9 @@ export class AiChatService implements OnModuleInit {
|
|||||||
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
|
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly pageAccess: PageAccessService,
|
private readonly pageAccess: PageAccessService,
|
||||||
|
// Reads the AI_CHAT_DEFERRED_TOOLS toggle (#332). Injected last so existing
|
||||||
|
// positional constructor callers (tests) only append one stub.
|
||||||
|
private readonly environment: EnvironmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -625,9 +664,25 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// Build the system prompt + Docmost toolset. If either throws after the
|
// Build the system prompt + Docmost toolset. If either throws after the
|
||||||
// external MCP lease was taken above, release the lease before rethrowing so
|
// external MCP lease was taken above, release the lease before rethrowing so
|
||||||
// the leased transports are not leaked (#185 review).
|
// the leased transports are not leaked (#185 review).
|
||||||
|
// Deferred tool loading toggle (#332). When ON, the model sees a compact
|
||||||
|
// <tool_catalog> and only CORE tools + loadTools are active each step; other
|
||||||
|
// tools (fat/rare in-app tools + ALL external MCP tools) load on demand. When
|
||||||
|
// OFF, every tool is active and nothing below changes.
|
||||||
|
const deferredEnabled = this.environment.isAiChatDeferredToolsEnabled();
|
||||||
|
|
||||||
let system: string;
|
let system: string;
|
||||||
let docmostTools: Awaited<ReturnType<AiChatToolsService['forUser']>>;
|
let docmostTools: Awaited<ReturnType<AiChatToolsService['forUser']>>;
|
||||||
try {
|
try {
|
||||||
|
// Assemble the deferred catalog for the system prompt: hand-written lines
|
||||||
|
// for the in-app deferred tools + a derived line for each external MCP tool
|
||||||
|
// (also deferred by default). Only built when the feature is enabled.
|
||||||
|
const toolCatalog = deferredEnabled
|
||||||
|
? [
|
||||||
|
...(await this.tools.getInAppDeferredCatalog()),
|
||||||
|
...buildExternalToolCatalog(external.tools),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
system = buildSystemPrompt({
|
system = buildSystemPrompt({
|
||||||
workspace,
|
workspace,
|
||||||
adminPrompt: resolved?.systemPrompt,
|
adminPrompt: resolved?.systemPrompt,
|
||||||
@@ -644,6 +699,10 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// Detected between-turns human edit to the open page (#274): adds the
|
// Detected between-turns human edit to the open page (#274): adds the
|
||||||
// page_changed note + unified diff so the agent doesn't overwrite it.
|
// page_changed note + unified diff so the agent doesn't overwrite it.
|
||||||
pageChanged,
|
pageChanged,
|
||||||
|
// Deferred tool loading (#332): renders the <tool_catalog> block (only
|
||||||
|
// when enabled + non-empty) so the model can activate deferred tools.
|
||||||
|
deferredToolsEnabled: deferredEnabled,
|
||||||
|
toolCatalog,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pass the resolved chatId so the write tools can mint provenance tokens
|
// Pass the resolved chatId so the write tools can mint provenance tokens
|
||||||
@@ -664,7 +723,31 @@ export class AiChatService implements OnModuleInit {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = { ...external.tools, ...docmostTools };
|
// Base toolset: external MCP tools + Docmost in-app tools (Docmost wins on a
|
||||||
|
// name clash — external are namespaced, so no clash is expected).
|
||||||
|
const baseTools = { ...external.tools, ...docmostTools };
|
||||||
|
|
||||||
|
// Deferred tool loading state (#332), scoped to THIS streaming loop:
|
||||||
|
// - `activatedTools` is per-TURN mutable state — a fresh closure Set created
|
||||||
|
// per streamText call, NOT module-global and NOT persisted, so a new turn
|
||||||
|
// starts cold. loadTools.execute adds to it; prepareAgentStep reads it to
|
||||||
|
// widen `activeTools` on the NEXT step.
|
||||||
|
// - `validDeferredNames` = every tool that is NOT core (the in-app deferred
|
||||||
|
// tools + ALL external MCP tools), computed from the ACTUAL toolset so an
|
||||||
|
// external tool is loadable by its namespaced name. loadTools rejects any
|
||||||
|
// name outside this set.
|
||||||
|
const activatedTools = new Set<string>();
|
||||||
|
const validDeferredNames = new Set<string>(
|
||||||
|
Object.keys(baseTools).filter((k) => !CORE_TOOL_SET.has(k)),
|
||||||
|
);
|
||||||
|
// Add the loadTools meta-tool ONLY when the feature is enabled; when off the
|
||||||
|
// toolset and behavior are exactly as before.
|
||||||
|
const tools = deferredEnabled
|
||||||
|
? {
|
||||||
|
...baseTools,
|
||||||
|
[LOAD_TOOLS_NAME]: makeLoadToolsTool(activatedTools, validDeferredNames),
|
||||||
|
}
|
||||||
|
: baseTools;
|
||||||
|
|
||||||
// Accumulate the turn's streamed output so a provider error / disconnect can
|
// Accumulate the turn's streamed output so a provider error / disconnect can
|
||||||
// persist the PARTIAL answer the user already saw — the SDK's onError/onAbort
|
// persist the PARTIAL answer the user already saw — the SDK's onError/onAbort
|
||||||
@@ -799,7 +882,8 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// ends with no assistant text (an empty turn). prepareAgentStep forbids
|
// ends with no assistant text (an empty turn). prepareAgentStep forbids
|
||||||
// further tool calls and appends a synthesis instruction on that step,
|
// further tool calls and appends a synthesis instruction on that step,
|
||||||
// concatenated onto the original `system` so the persona is preserved.
|
// concatenated onto the original `system` so the persona is preserved.
|
||||||
prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system),
|
prepareStep: ({ stepNumber }) =>
|
||||||
|
prepareAgentStep(stepNumber, system, activatedTools, deferredEnabled),
|
||||||
abortSignal: signal,
|
abortSignal: signal,
|
||||||
onChunk: ({ chunk }) => {
|
onChunk: ({ chunk }) => {
|
||||||
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
|
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import { resolveCurrentPageResult } from './current-page.util';
|
|||||||
import { parseNodeArg } from './parse-node-arg';
|
import { parseNodeArg } from './parse-node-arg';
|
||||||
import { modelFriendlyInput } from './model-friendly-input';
|
import { modelFriendlyInput } from './model-friendly-input';
|
||||||
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
||||||
|
import {
|
||||||
|
buildInAppDeferredCatalog,
|
||||||
|
type ToolCatalogEntry,
|
||||||
|
} from './tool-tiers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-user, per-request adapter that exposes Docmost READ operations to the
|
* Per-user, per-request adapter that exposes Docmost READ operations to the
|
||||||
@@ -123,6 +127,18 @@ export class AiChatToolsService {
|
|||||||
return client.exportPageMarkdown(pageId);
|
return client.exportPageMarkdown(pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the IN-APP deferred <tool_catalog> entries (#332): one "name — purpose"
|
||||||
|
* line per DEFERRED tool, merging the per-layer INLINE_TOOL_TIERS with the
|
||||||
|
* shared registry's own catalogLine. Loads @docmost/mcp for the shared specs
|
||||||
|
* (memoized). Core tools are always active and are NOT listed here. External
|
||||||
|
* MCP tools are catalogued separately by the caller (they are runtime-scoped).
|
||||||
|
*/
|
||||||
|
async getInAppDeferredCatalog(): Promise<ToolCatalogEntry[]> {
|
||||||
|
const { sharedToolSpecs } = await loadDocmostMcp();
|
||||||
|
return buildInAppDeferredCatalog(sharedToolSpecs);
|
||||||
|
}
|
||||||
|
|
||||||
async forUser(
|
async forUser(
|
||||||
user: User,
|
user: User,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -630,6 +646,16 @@ export class AiChatToolsService {
|
|||||||
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
|
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
searchInPage: sharedTool(
|
||||||
|
sharedToolSpecs.searchInPage,
|
||||||
|
async ({ pageId, query, regex, caseSensitive, limit }) =>
|
||||||
|
await client.searchInPage(pageId, query, {
|
||||||
|
regex,
|
||||||
|
caseSensitive,
|
||||||
|
limit,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
getTable: tool({
|
getTable: tool({
|
||||||
description:
|
description:
|
||||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ export interface DocmostClientLike {
|
|||||||
getOutline(pageId: string): Promise<Record<string, unknown>>;
|
getOutline(pageId: string): Promise<Record<string, unknown>>;
|
||||||
getPageJson(pageId: string): Promise<Record<string, unknown>>;
|
getPageJson(pageId: string): Promise<Record<string, unknown>>;
|
||||||
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
|
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
|
||||||
|
searchInPage(
|
||||||
|
pageId: string,
|
||||||
|
query: string,
|
||||||
|
opts?: { regex?: boolean; caseSensitive?: boolean; limit?: number },
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
|
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
|
||||||
// Returns `{ items, resolvedThreadsHidden }`. DEFAULT (includeResolved unset/
|
// Returns `{ items, resolvedThreadsHidden }`. DEFAULT (includeResolved unset/
|
||||||
// false) hides resolved threads wholesale; pass true for the full feed.
|
// false) hides resolved threads wholesale; pass true for the full feed.
|
||||||
@@ -236,6 +241,11 @@ export interface SharedToolSpec {
|
|||||||
mcpName: string;
|
mcpName: string;
|
||||||
inAppKey: string;
|
inAppKey: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
// Deferred-tool metadata (#332). Optional in this mirror so an older/stale
|
||||||
|
// @docmost/mcp build (pre-#332) still type-checks; the in-app catalog builder
|
||||||
|
// reads them defensively. The external /mcp server ignores both fields.
|
||||||
|
tier?: 'core' | 'deferred';
|
||||||
|
catalogLine?: string;
|
||||||
// Loose `z` on purpose: the registry is zod-agnostic so the server can pass
|
// Loose `z` on purpose: the registry is zod-agnostic so the server can pass
|
||||||
// its own zod (v4) and the MCP package its own (v3) into the same builder.
|
// its own zod (v4) and the MCP package its own (v3) into the same builder.
|
||||||
buildShape?: (z: any) => Record<string, unknown>;
|
buildShape?: (z: any) => Record<string, unknown>;
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import {
|
||||||
|
CORE_TOOL_KEYS,
|
||||||
|
CORE_TOOL_SET,
|
||||||
|
LOAD_TOOLS_NAME,
|
||||||
|
LOAD_TOOLS_DESCRIPTION,
|
||||||
|
INLINE_TOOL_TIERS,
|
||||||
|
buildInAppDeferredCatalog,
|
||||||
|
buildExternalToolCatalog,
|
||||||
|
shortenForCatalog,
|
||||||
|
applyLoadTools,
|
||||||
|
} from './tool-tiers';
|
||||||
|
// The real shared registry, imported from source (same approach as the
|
||||||
|
// SHARED_TOOL_SPECS contract spec) so the tier metadata is checked against
|
||||||
|
// exactly what @docmost/mcp ships.
|
||||||
|
import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
|
||||||
|
// For the live-toolset partition test (F3): the REAL adapter, so the catalog is
|
||||||
|
// checked against the tools AiChatToolsService.forUser() actually builds — not a
|
||||||
|
// static list that could drift from it.
|
||||||
|
import { AiChatToolsService } from './ai-chat-tools.service';
|
||||||
|
import * as loader from './docmost-client.loader';
|
||||||
|
import type { DocmostClientLike } from './docmost-client.loader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #332 deferred tool loading — tier metadata, catalog assembly, and the
|
||||||
|
* loadTools meta-tool. Pure units; no Nest graph, no @docmost/mcp build (the
|
||||||
|
* registry is imported from TS source).
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('tool tier metadata (#332)', () => {
|
||||||
|
it('core set is the documented 13 + searchInPage (14)', () => {
|
||||||
|
expect(CORE_TOOL_KEYS).toHaveLength(14);
|
||||||
|
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
|
||||||
|
// loadTools is a meta-tool, not a normal core key.
|
||||||
|
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARED_TOOL_SPECS tier agrees with CORE_TOOL_SET for every shared tool', () => {
|
||||||
|
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
||||||
|
const isCoreByTier = spec.tier === 'core';
|
||||||
|
const isCoreByList = CORE_TOOL_SET.has(key);
|
||||||
|
expect(isCoreByTier).toBe(isCoreByList);
|
||||||
|
// Every spec carries a non-empty catalogLine (core tools too).
|
||||||
|
expect(typeof spec.catalogLine).toBe('string');
|
||||||
|
expect(spec.catalogLine.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every INLINE tool tier agrees with CORE_TOOL_SET and has a catalogLine', () => {
|
||||||
|
for (const [key, meta] of Object.entries(INLINE_TOOL_TIERS)) {
|
||||||
|
expect(meta.tier === 'core').toBe(CORE_TOOL_SET.has(key));
|
||||||
|
expect(meta.catalogLine.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildInAppDeferredCatalog (#332)', () => {
|
||||||
|
const catalog = buildInAppDeferredCatalog(SHARED_TOOL_SPECS as never);
|
||||||
|
const names = catalog.map((e) => e.name);
|
||||||
|
|
||||||
|
it('includes deferred tools from BOTH the inline map and the shared registry', () => {
|
||||||
|
expect(names).toContain('transformPage'); // inline deferred
|
||||||
|
expect(names).toContain('getPageJson'); // shared deferred
|
||||||
|
expect(names).toContain('patchNode'); // shared deferred
|
||||||
|
expect(names).toContain('createPage'); // inline deferred
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NEVER lists a core tool', () => {
|
||||||
|
for (const core of CORE_TOOL_KEYS) {
|
||||||
|
expect(names).not.toContain(core);
|
||||||
|
}
|
||||||
|
// spot-check a couple that are core in each source.
|
||||||
|
expect(names).not.toContain('searchInPage'); // shared core
|
||||||
|
expect(names).not.toContain('searchPages'); // inline core
|
||||||
|
expect(names).not.toContain('editPageText'); // shared core
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders every entry as a "name — purpose" line', () => {
|
||||||
|
// Non-empty catalog (the length is pinned structurally by the live-toolset
|
||||||
|
// partition test below, not by a magic constant that rots on every new tool).
|
||||||
|
expect(catalog.length).toBeGreaterThan(0);
|
||||||
|
for (const entry of catalog) {
|
||||||
|
expect(entry.catalogLine).toMatch(/ — /);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F3 — the deferred <tool_catalog> is built from STATIC metadata (INLINE_TOOL_TIERS
|
||||||
|
* + SHARED_TOOL_SPECS), but the loadable-by-name set is derived at RUNTIME from the
|
||||||
|
* actual toolset (`Object.keys(baseTools)` in ai-chat.service.ts). Those two must
|
||||||
|
* agree or a tool becomes loadable-but-invisible (agent thinks it doesn't exist) or
|
||||||
|
* catalogued-but-phantom. INLINE_TOOL_TIERS is a plain hand-maintained Record with
|
||||||
|
* no compile-time link to the tools AiChatToolsService.forUser() builds, so nothing
|
||||||
|
* else catches that drift. This test uses forUser()'s LIVE keys as the source of
|
||||||
|
* truth (mirroring ai-chat-tools.service.spec.ts's loader mock) and asserts a
|
||||||
|
* two-way partition against buildInAppDeferredCatalog — replacing the old magic
|
||||||
|
* toHaveLength(28), so a tool added to forUser() without a catalog line (or a
|
||||||
|
* catalog line without a real tool) fails the suite instead of silently vanishing.
|
||||||
|
*/
|
||||||
|
describe('deferred catalog ↔ live forUser() toolset partition (#332, F3)', () => {
|
||||||
|
let toolKeys: string[];
|
||||||
|
const catalogNames = buildInAppDeferredCatalog(SHARED_TOOL_SPECS as never).map(
|
||||||
|
(e) => e.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Intercept the ESM loader so forUser() builds against the TS-source shared
|
||||||
|
// specs (no @docmost/mcp build) and never touches the network.
|
||||||
|
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
||||||
|
DocmostClient: function () {
|
||||||
|
return {} as DocmostClientLike;
|
||||||
|
} as unknown as loader.DocmostClientCtor,
|
||||||
|
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
|
||||||
|
});
|
||||||
|
const service = new AiChatToolsService(
|
||||||
|
{
|
||||||
|
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||||
|
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||||
|
} as never,
|
||||||
|
{} as never, // aiService — not exercised while merely BUILDING the tools
|
||||||
|
{} as never, // pageEmbeddingRepo
|
||||||
|
{} as never, // spaceMemberRepo
|
||||||
|
{} as never, // pagePermissionRepo
|
||||||
|
// sandboxStore: forUser() eagerly calls asSink() to wire the stash tool.
|
||||||
|
{
|
||||||
|
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
const tools = await service.forUser(
|
||||||
|
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||||
|
'session-1',
|
||||||
|
'ws-1',
|
||||||
|
'chat-1',
|
||||||
|
);
|
||||||
|
toolKeys = Object.keys(tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a non-trivial toolset (sanity: the mock actually built tools)', () => {
|
||||||
|
expect(toolKeys.length).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every non-core live tool is present in the catalog (no capability silently hidden)', () => {
|
||||||
|
// forUser() does not itself add loadTools (ai-chat.service does), but guard
|
||||||
|
// anyway. Every remaining non-core key MUST have a catalog line.
|
||||||
|
const catalogSet = new Set(catalogNames);
|
||||||
|
const missing = toolKeys.filter(
|
||||||
|
(k) => !CORE_TOOL_SET.has(k) && k !== LOAD_TOOLS_NAME && !catalogSet.has(k),
|
||||||
|
);
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every catalog entry corresponds to a real, non-core live tool (no phantom)', () => {
|
||||||
|
const liveSet = new Set(toolKeys);
|
||||||
|
const phantom = catalogNames.filter(
|
||||||
|
(n) => !liveSet.has(n) || CORE_TOOL_SET.has(n),
|
||||||
|
);
|
||||||
|
expect(phantom).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildExternalToolCatalog + shortenForCatalog (#332)', () => {
|
||||||
|
it('derives a short "name — purpose" line from each external tool description', () => {
|
||||||
|
const catalog = buildExternalToolCatalog({
|
||||||
|
tavily_search: { description: 'Search the web for fresh results. More detail here.' },
|
||||||
|
tavily_extract: { description: '' },
|
||||||
|
});
|
||||||
|
expect(catalog).toEqual([
|
||||||
|
{ name: 'tavily_search', catalogLine: 'tavily_search — Search the web for fresh results.' },
|
||||||
|
{ name: 'tavily_extract', catalogLine: 'tavily_extract — external tool' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps a very long description', () => {
|
||||||
|
const long = 'x'.repeat(500);
|
||||||
|
expect(shortenForCatalog(long).length).toBeLessThanOrEqual(140);
|
||||||
|
expect(shortenForCatalog(long).endsWith('…')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyLoadTools (#332)', () => {
|
||||||
|
const valid = new Set(['createPage', 'transformPage', 'tavily_search']);
|
||||||
|
|
||||||
|
it('adds valid names to the activated set and returns { loaded }', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
const result = applyLoadTools(['createPage', 'tavily_search'], activated, valid);
|
||||||
|
expect(result).toEqual({ loaded: ['createPage', 'tavily_search'] });
|
||||||
|
expect(activated.has('createPage')).toBe(true);
|
||||||
|
expect(activated.has('tavily_search')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown name with an error listing the valid deferred names', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
expect(() => applyLoadTools(['nope'], activated, valid)).toThrow(/unknown tool name/i);
|
||||||
|
try {
|
||||||
|
applyLoadTools(['nope'], activated, valid);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as Error).message;
|
||||||
|
// Lists every valid name (sorted).
|
||||||
|
expect(msg).toContain('createPage');
|
||||||
|
expect(msg).toContain('transformPage');
|
||||||
|
expect(msg).toContain('tavily_search');
|
||||||
|
}
|
||||||
|
// Nothing is activated on a rejected call.
|
||||||
|
expect(activated.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a non-array / empty input (loads nothing)', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
expect(applyLoadTools(undefined, activated, valid)).toEqual({ loaded: [] });
|
||||||
|
expect(applyLoadTools([], activated, valid)).toEqual({ loaded: [] });
|
||||||
|
expect(activated.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadTools description is the verbatim issue text', () => {
|
||||||
|
expect(LOAD_TOOLS_DESCRIPTION).toContain('only ACTIVATES them');
|
||||||
|
expect(LOAD_TOOLS_DESCRIPTION).toContain('callable on your NEXT step');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editorial "Corrector" scenario is fully served by CORE (#332)', () => {
|
||||||
|
it('read + comment + edit + search need no loadTools', () => {
|
||||||
|
// A Corrector role reads a page, searches within it, edits text, and leaves
|
||||||
|
// inline comments — every tool it needs is core, so it never has to load a
|
||||||
|
// deferred tool.
|
||||||
|
const needed = [
|
||||||
|
'getCurrentPage',
|
||||||
|
'getPage',
|
||||||
|
'searchPages',
|
||||||
|
'searchInPage',
|
||||||
|
'editPageText',
|
||||||
|
'createComment',
|
||||||
|
'listComments',
|
||||||
|
'getComment',
|
||||||
|
'resolveComment',
|
||||||
|
];
|
||||||
|
for (const t of needed) {
|
||||||
|
expect(CORE_TOOL_SET.has(t)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { tool, type Tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { SharedToolSpec } from './docmost-client.loader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deferred tool loading for the in-app AI chat (#332).
|
||||||
|
*
|
||||||
|
* The agent otherwise sends ALL ~41 tool definitions on EVERY model call every
|
||||||
|
* step, bloating context. Instead we split the in-app tools into two tiers:
|
||||||
|
*
|
||||||
|
* - CORE (hot, always active): frequent OR tiny tools whose full schema is
|
||||||
|
* always visible, plus the `loadTools` meta-tool. Deferring a one-line tool is
|
||||||
|
* pure loss, so tiny tools stay core even if rare.
|
||||||
|
* - DEFERRED (loaded on demand): the fat/rare tools + ALL external MCP tools by
|
||||||
|
* default. The model sees only a compact <tool_catalog> (name — purpose) and
|
||||||
|
* calls `loadTools(names)` to ACTIVATE a tool's full schema for the NEXT step
|
||||||
|
* (one extra round-trip on first use).
|
||||||
|
*
|
||||||
|
* This module is the single source of truth for the IN-APP tiering:
|
||||||
|
* - CORE_TOOL_KEYS / CORE_TOOL_SET — the authoritative core list (used by
|
||||||
|
* prepareAgentStep to build per-step `activeTools`).
|
||||||
|
* - INLINE_TOOL_TIERS — tier + catalogLine for the per-layer INLINE tools (the
|
||||||
|
* ones NOT in @docmost/mcp's SHARED_TOOL_SPECS, which carry their own).
|
||||||
|
* - buildInAppDeferredCatalog / buildExternalToolCatalog — assemble the
|
||||||
|
* <tool_catalog> deferred lines.
|
||||||
|
* - applyLoadTools / makeLoadToolsTool — the loadTools meta-tool.
|
||||||
|
*
|
||||||
|
* The tier/catalogLine fields on SHARED_TOOL_SPECS are IN-APP metadata only; the
|
||||||
|
* external /mcp server ignores them and exposes every tool normally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A single rendered <tool_catalog> line: the tool name + its "name — purpose". */
|
||||||
|
export interface ToolCatalogEntry {
|
||||||
|
/** Exact tool name the model must pass to loadTools. */
|
||||||
|
name: string;
|
||||||
|
/** Hand-written (in-app) or derived (external) "name — purpose" line. */
|
||||||
|
catalogLine: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORE (always-active) in-app tool keys — 13 frequent/tiny tools. `searchInPage`
|
||||||
|
* (#330) is added to core on top of the issue's original tier list: it is
|
||||||
|
* frequent for the editorial roles this feature targets. `loadTools` is active
|
||||||
|
* too but is not a normal tool key (it is added to activeTools separately).
|
||||||
|
*/
|
||||||
|
export const CORE_TOOL_KEYS = [
|
||||||
|
'searchPages',
|
||||||
|
'listPages',
|
||||||
|
'listSpaces',
|
||||||
|
'getWorkspace',
|
||||||
|
'getCurrentPage',
|
||||||
|
'getPage',
|
||||||
|
'getOutline',
|
||||||
|
'getNode',
|
||||||
|
'createComment',
|
||||||
|
'getComment',
|
||||||
|
'listComments',
|
||||||
|
'resolveComment',
|
||||||
|
'editPageText',
|
||||||
|
// #330 search_in_page — frequent for editorial sweeps; core despite predating
|
||||||
|
// the issue's tier list.
|
||||||
|
'searchInPage',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** O(1) membership test for the core tier. */
|
||||||
|
export const CORE_TOOL_SET: ReadonlySet<string> = new Set(CORE_TOOL_KEYS);
|
||||||
|
|
||||||
|
/** The meta-tool name (always active alongside the core tools when enabled). */
|
||||||
|
export const LOAD_TOOLS_NAME = 'loadTools';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* loadTools description — VERBATIM from issue #332. Tells the model that the
|
||||||
|
* catalog names EXIST, that loadTools only ACTIVATES them (callable next step),
|
||||||
|
* and to load several at once.
|
||||||
|
*/
|
||||||
|
export const LOAD_TOOLS_DESCRIPTION =
|
||||||
|
'loadTools — Load the full definitions of deferred tools from the <tool_catalog>\n' +
|
||||||
|
'block in your instructions. Pass the EXACT tool names from the catalog; this\n' +
|
||||||
|
'call only ACTIVATES them and returns { loaded: [...] } — the tools become\n' +
|
||||||
|
'callable on your NEXT step. Load several names in one call when the task clearly\n' +
|
||||||
|
'needs them. Unknown names are rejected with the list of valid ones.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tier + catalogLine for the INLINE ai-chat tools — those defined per-layer in
|
||||||
|
* ai-chat-tools.service.ts and NOT present in @docmost/mcp's SHARED_TOOL_SPECS
|
||||||
|
* (which carries its own tier/catalogLine). Together with the shared registry
|
||||||
|
* this describes every in-app tool. catalogLine is present for core tools too
|
||||||
|
* (uniformity), but only DEFERRED tools are rendered into the catalog.
|
||||||
|
*/
|
||||||
|
export const INLINE_TOOL_TIERS: Record<
|
||||||
|
string,
|
||||||
|
{ tier: 'core' | 'deferred'; catalogLine: string }
|
||||||
|
> = {
|
||||||
|
// --- core inline ---
|
||||||
|
searchPages: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'searchPages — hybrid semantic + keyword search across the wiki.',
|
||||||
|
},
|
||||||
|
getCurrentPage: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
||||||
|
},
|
||||||
|
getPage: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
||||||
|
},
|
||||||
|
listPages: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
||||||
|
},
|
||||||
|
listComments: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'listComments — list all comments on a page (including resolved).',
|
||||||
|
},
|
||||||
|
getComment: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getComment — fetch a single comment by id.',
|
||||||
|
},
|
||||||
|
createComment: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
'createComment — add an inline comment (optionally with a suggested edit).',
|
||||||
|
},
|
||||||
|
resolveComment: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- deferred inline ---
|
||||||
|
createPage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
|
||||||
|
},
|
||||||
|
updatePageContent: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
||||||
|
},
|
||||||
|
renamePage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: "renamePage — change a page's title only (body untouched).",
|
||||||
|
},
|
||||||
|
movePage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'movePage — move a page under a new parent or to the space root.',
|
||||||
|
},
|
||||||
|
deletePage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
|
||||||
|
},
|
||||||
|
listSidebarPages: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"listSidebarPages — list a space's root pages or a page's direct children.",
|
||||||
|
},
|
||||||
|
getTable: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
||||||
|
},
|
||||||
|
checkNewComments: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'checkNewComments — find comments in a space created after a timestamp.',
|
||||||
|
},
|
||||||
|
getPageHistory: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
||||||
|
},
|
||||||
|
exportPageMarkdown: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
||||||
|
},
|
||||||
|
updatePageJson: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
|
||||||
|
},
|
||||||
|
tableInsertRow: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
|
||||||
|
},
|
||||||
|
tableDeleteRow: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
|
||||||
|
},
|
||||||
|
tableUpdateCell: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
|
||||||
|
},
|
||||||
|
sharePage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
|
||||||
|
},
|
||||||
|
transformPage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the <tool_catalog> deferred lines for the IN-APP tools by merging the
|
||||||
|
* two metadata sources: the per-layer INLINE_TOOL_TIERS and the shared registry
|
||||||
|
* (SHARED_TOOL_SPECS, loaded at runtime). Only DEFERRED tools are included; core
|
||||||
|
* tools are always active and never appear in the catalog. Pure — the caller
|
||||||
|
* passes the loaded specs so this stays unit-testable.
|
||||||
|
*/
|
||||||
|
export function buildInAppDeferredCatalog(
|
||||||
|
sharedToolSpecs: Record<string, SharedToolSpec>,
|
||||||
|
): ToolCatalogEntry[] {
|
||||||
|
const entries: ToolCatalogEntry[] = [];
|
||||||
|
// Inline deferred tools (hand-written lines).
|
||||||
|
for (const [name, meta] of Object.entries(INLINE_TOOL_TIERS)) {
|
||||||
|
if (meta.tier === 'deferred') {
|
||||||
|
entries.push({ name, catalogLine: meta.catalogLine });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shared deferred tools (line comes from the registry's own catalogLine).
|
||||||
|
for (const [name, spec] of Object.entries(sharedToolSpecs)) {
|
||||||
|
if (spec.tier === 'deferred' && spec.catalogLine) {
|
||||||
|
entries.push({ name, catalogLine: spec.catalogLine });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cap an external tool's (untrusted) description into a short catalog purpose.
|
||||||
|
* External MCP tools have no hand-written catalogLine, so we derive one from the
|
||||||
|
* first sentence of the description, hard-capped. Whitespace is collapsed.
|
||||||
|
*/
|
||||||
|
export function shortenForCatalog(description: string, max = 140): string {
|
||||||
|
const flat = description.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!flat) return 'external tool';
|
||||||
|
// Prefer the first sentence if it is reasonably short.
|
||||||
|
const firstSentence = flat.split(/(?<=[.!?])\s/)[0];
|
||||||
|
const base =
|
||||||
|
firstSentence.length > 0 && firstSentence.length <= max
|
||||||
|
? firstSentence
|
||||||
|
: flat;
|
||||||
|
return base.length > max ? `${base.slice(0, max - 1).trimEnd()}…` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build catalog lines for the EXTERNAL MCP tools (all deferred by default,
|
||||||
|
* #332). Their names are the namespaced tool keys; the purpose is derived from
|
||||||
|
* each tool's own description (no hand-written line exists). Pure.
|
||||||
|
*/
|
||||||
|
export function buildExternalToolCatalog(
|
||||||
|
externalTools: Record<string, { description?: string } | undefined>,
|
||||||
|
): ToolCatalogEntry[] {
|
||||||
|
return Object.entries(externalTools).map(([name, t]) => ({
|
||||||
|
name,
|
||||||
|
catalogLine: `${name} — ${shortenForCatalog(t?.description ?? '')}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure core of the loadTools meta-tool. Validates the requested names against
|
||||||
|
* the per-turn set of valid deferred names, ADDS the valid ones to the caller's
|
||||||
|
* mutable `activatedTools` set (so they become callable next step), and returns
|
||||||
|
* `{ loaded }`. An unknown name throws a clear error listing the valid deferred
|
||||||
|
* names — surfaced to the model as a tool error so it can retry.
|
||||||
|
*/
|
||||||
|
export function applyLoadTools(
|
||||||
|
names: unknown,
|
||||||
|
activatedTools: Set<string>,
|
||||||
|
validDeferredNames: ReadonlySet<string>,
|
||||||
|
): { loaded: string[] } {
|
||||||
|
const requested = Array.isArray(names)
|
||||||
|
? names.filter((n): n is string => typeof n === 'string')
|
||||||
|
: [];
|
||||||
|
const unknown = requested.filter((n) => !validDeferredNames.has(n));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
const valid = [...validDeferredNames].sort().join(', ');
|
||||||
|
throw new Error(
|
||||||
|
`loadTools: unknown tool name(s): ${unknown.join(', ')}. ` +
|
||||||
|
`Valid deferred tools are: ${valid || '(none)'}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const n of requested) activatedTools.add(n);
|
||||||
|
return { loaded: requested };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the loadTools AI-SDK tool bound to THIS turn's mutable state: the
|
||||||
|
* `activatedTools` set (grown by execute, read by prepareAgentStep next step)
|
||||||
|
* and the `validDeferredNames` set (every non-core tool in this turn's toolset,
|
||||||
|
* incl. external MCP). Created per streamText call — never module-global.
|
||||||
|
*/
|
||||||
|
export function makeLoadToolsTool(
|
||||||
|
activatedTools: Set<string>,
|
||||||
|
validDeferredNames: ReadonlySet<string>,
|
||||||
|
): Tool {
|
||||||
|
return tool({
|
||||||
|
description: LOAD_TOOLS_DESCRIPTION,
|
||||||
|
inputSchema: z.object({
|
||||||
|
names: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe(
|
||||||
|
'EXACT deferred tool names from the <tool_catalog> to activate for ' +
|
||||||
|
'your next step.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: async ({ names }) =>
|
||||||
|
applyLoadTools(names, activatedTools, validDeferredNames),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -117,3 +118,207 @@ describe('CommentController apply-suggestion authz', () => {
|
|||||||
expect(commentService.applySuggestion).not.toHaveBeenCalled();
|
expect(commentService.applySuggestion).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authz-gate tests for the dismiss-suggestion route (#329). Dismissing a
|
||||||
|
* suggestion does NOT change the page text, so it authorizes with
|
||||||
|
* validateCanComment (NOT validateCanEdit) — a viewer allowed to comment but not
|
||||||
|
* edit can still dismiss. The gate MUST run BEFORE the service (which performs
|
||||||
|
* the delete/resolve + mark removal). These tests pin that boundary.
|
||||||
|
*/
|
||||||
|
describe('CommentController dismiss-suggestion authz', () => {
|
||||||
|
// isAdmin=false → ability.cannot(Manage, Settings) returns true (i.e. the user
|
||||||
|
// is NOT a space admin). Flip to true to model a space admin.
|
||||||
|
function makeController(isAdmin = false) {
|
||||||
|
const commentService = {
|
||||||
|
dismissSuggestion: jest.fn(async () => ({
|
||||||
|
id: 'c-1',
|
||||||
|
outcome: 'deleted',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const commentRepo = { findById: jest.fn() };
|
||||||
|
const pageRepo = { findById: jest.fn() };
|
||||||
|
const spaceAbility = {
|
||||||
|
createForUser: jest.fn(async () => ({
|
||||||
|
cannot: jest.fn(() => !isAdmin),
|
||||||
|
})),
|
||||||
|
} as any;
|
||||||
|
const pageAccessService = {
|
||||||
|
validateCanComment: jest.fn(async () => undefined),
|
||||||
|
validateCanEdit: jest.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
const wsService = {} as any;
|
||||||
|
const auditService = { log: jest.fn() };
|
||||||
|
|
||||||
|
const controller = new CommentController(
|
||||||
|
commentService as any,
|
||||||
|
commentRepo as any,
|
||||||
|
pageRepo as any,
|
||||||
|
spaceAbility,
|
||||||
|
pageAccessService as any,
|
||||||
|
wsService,
|
||||||
|
auditService as any,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
controller,
|
||||||
|
commentService,
|
||||||
|
commentRepo,
|
||||||
|
pageRepo,
|
||||||
|
pageAccessService,
|
||||||
|
spaceAbility,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user: any = { id: 'u-1' };
|
||||||
|
const workspace: any = { id: 'ws-1' };
|
||||||
|
const provenance: any = undefined;
|
||||||
|
const dto: any = { commentId: 'c-1' };
|
||||||
|
// Owned by the acting user (u-1) unless a test overrides creatorId.
|
||||||
|
const comment = {
|
||||||
|
id: 'c-1',
|
||||||
|
pageId: 'p-1',
|
||||||
|
spaceId: 'sp-1',
|
||||||
|
creatorId: 'u-1',
|
||||||
|
suggestedText: 'new text',
|
||||||
|
selection: 'old text',
|
||||||
|
};
|
||||||
|
const page = { id: 'p-1', spaceId: 'sp-1', deletedAt: null };
|
||||||
|
|
||||||
|
it('authorizes with validateCanComment (NOT validateCanEdit) then calls the service', async () => {
|
||||||
|
const {
|
||||||
|
controller,
|
||||||
|
commentRepo,
|
||||||
|
pageRepo,
|
||||||
|
pageAccessService,
|
||||||
|
commentService,
|
||||||
|
} = makeController();
|
||||||
|
commentRepo.findById.mockResolvedValue(comment);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
const dismissed = { id: 'c-1', outcome: 'deleted' };
|
||||||
|
commentService.dismissSuggestion.mockResolvedValue(dismissed);
|
||||||
|
|
||||||
|
const result = await controller.dismissSuggestion(
|
||||||
|
dto,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pageAccessService.validateCanComment).toHaveBeenCalledWith(
|
||||||
|
page,
|
||||||
|
user,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
// Dismiss must NOT require edit access.
|
||||||
|
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||||
|
expect(commentService.dismissSuggestion).toHaveBeenCalledWith(
|
||||||
|
comment,
|
||||||
|
user,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
expect(result).toBe(dismissed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateCanComment throwing Forbidden rejects AND dismissSuggestion is never called', async () => {
|
||||||
|
const {
|
||||||
|
controller,
|
||||||
|
commentRepo,
|
||||||
|
pageRepo,
|
||||||
|
pageAccessService,
|
||||||
|
commentService,
|
||||||
|
} = makeController();
|
||||||
|
commentRepo.findById.mockResolvedValue(comment);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
pageAccessService.validateCanComment.mockRejectedValue(
|
||||||
|
new ForbiddenException('no comment access'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
|
||||||
|
expect(commentService.dismissSuggestion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('missing comment: NotFound without authorizing or dismissing', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, pageAccessService, commentService } =
|
||||||
|
makeController();
|
||||||
|
commentRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
|
||||||
|
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||||
|
expect(pageAccessService.validateCanComment).not.toHaveBeenCalled();
|
||||||
|
expect(commentService.dismissSuggestion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a service BadRequest (e.g. already applied/resolved) unchanged', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, commentService } =
|
||||||
|
makeController();
|
||||||
|
commentRepo.findById.mockResolvedValue(comment);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
commentService.dismissSuggestion.mockRejectedValue(
|
||||||
|
new BadRequestException('already applied'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- #338 owner-or-space-admin gate (mirrors POST /comments/delete) --------
|
||||||
|
// A childless dismiss irreversibly hard-deletes the comment, so canComment is
|
||||||
|
// not enough: only the comment owner or a space admin may dismiss.
|
||||||
|
|
||||||
|
it('owner dismisses their own suggestion → allowed, no admin check needed', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, commentService, spaceAbility } =
|
||||||
|
makeController(false);
|
||||||
|
// comment.creatorId === user.id (owner).
|
||||||
|
commentRepo.findById.mockResolvedValue(comment);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await controller.dismissSuggestion(dto, user, workspace, provenance);
|
||||||
|
|
||||||
|
// Owner short-circuits the admin lookup.
|
||||||
|
expect(spaceAbility.createForUser).not.toHaveBeenCalled();
|
||||||
|
expect(commentService.dismissSuggestion).toHaveBeenCalledWith(
|
||||||
|
comment,
|
||||||
|
user,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-owner non-admin → Forbidden AND the service is never called', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, commentService, spaceAbility } =
|
||||||
|
makeController(false); // NOT a space admin
|
||||||
|
commentRepo.findById.mockResolvedValue({
|
||||||
|
...comment,
|
||||||
|
creatorId: 'someone-else',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
|
||||||
|
expect(spaceAbility.createForUser).toHaveBeenCalledWith(user, comment.spaceId);
|
||||||
|
expect(commentService.dismissSuggestion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-owner space admin → allowed to dismiss another user’s suggestion', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, commentService, spaceAbility } =
|
||||||
|
makeController(true); // space admin
|
||||||
|
commentRepo.findById.mockResolvedValue({
|
||||||
|
...comment,
|
||||||
|
creatorId: 'someone-else',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await controller.dismissSuggestion(dto, user, workspace, provenance);
|
||||||
|
|
||||||
|
expect(spaceAbility.createForUser).toHaveBeenCalledWith(user, comment.spaceId);
|
||||||
|
expect(commentService.dismissSuggestion).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { CreateCommentDto } from './dto/create-comment.dto';
|
|||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { ResolveCommentDto } from './dto/resolve-comment.dto';
|
import { ResolveCommentDto } from './dto/resolve-comment.dto';
|
||||||
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
|
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
|
||||||
|
import { DismissSuggestionDto } from './dto/dismiss-suggestion.dto';
|
||||||
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
@@ -234,6 +235,59 @@ export class CommentController {
|
|||||||
return this.commentService.applySuggestion(comment, user, provenance);
|
return this.commentService.applySuggestion(comment, user, provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('dismiss-suggestion')
|
||||||
|
async dismissSuggestion(
|
||||||
|
@Body() dto: DismissSuggestionDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@AuthProvenance() provenance: AuthProvenanceData,
|
||||||
|
) {
|
||||||
|
const comment = await this.commentRepo.findById(dto.commentId, {
|
||||||
|
includeCreator: true,
|
||||||
|
includeResolvedBy: true,
|
||||||
|
});
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await this.pageRepo.findById(comment.pageId);
|
||||||
|
if (!page || page.deletedAt) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize BEFORE revealing any structural detail (metadata-disclosure
|
||||||
|
// hygiene, mirroring apply-suggestion). Dismissing a suggestion does NOT
|
||||||
|
// change the page text — it only removes/resolves the comment — so the
|
||||||
|
// page-level gate is comment access (canComment), NOT edit access. A viewer
|
||||||
|
// allowed to comment but not edit can still dismiss their own suggestion.
|
||||||
|
// The structural 400s (top-level / has-a-suggested-edit / not applied /
|
||||||
|
// not resolved) are re-checked by the service below.
|
||||||
|
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
||||||
|
|
||||||
|
// AUTHZ (#338): a childless dismiss IRREVERSIBLY hard-deletes the comment,
|
||||||
|
// so — beyond canComment — restrict it to the comment owner OR a space
|
||||||
|
// admin, exactly like POST /comments/delete. canComment alone is not enough:
|
||||||
|
// it would let any bystander commenter erase another user's suggestion for
|
||||||
|
// good. (apply-suggestion deliberately stays on canEdit: accepting an edit
|
||||||
|
// is the editor's semantics, not the suggestion author's.)
|
||||||
|
const isOwner = comment.creatorId === user.id;
|
||||||
|
if (!isOwner) {
|
||||||
|
const ability = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
comment.spaceId,
|
||||||
|
);
|
||||||
|
// Space admin can dismiss any suggestion.
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You can only dismiss your own suggestions',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.commentService.dismissSuggestion(comment, user, provenance);
|
||||||
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('delete')
|
@Post('delete')
|
||||||
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
||||||
|
|||||||
@@ -13,17 +13,27 @@ import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
|||||||
*
|
*
|
||||||
* The collaboration gateway verdict is the pivot of the whole flow, so each test
|
* The collaboration gateway verdict is the pivot of the whole flow, so each test
|
||||||
* pins a specific { applied, currentText } and asserts the DB persistence,
|
* pins a specific { applied, currentText } and asserts the DB persistence,
|
||||||
* auto-resolve, audit, ws broadcast, and error mapping that follow from it.
|
* settle (ephemeral delete vs. resolve), audit, ws broadcast, and error mapping
|
||||||
|
* that follow from it.
|
||||||
|
*
|
||||||
|
* Ephemeral rule (#329): once applied a suggestion DISAPPEARS (hard-delete +
|
||||||
|
* strip the inline anchor mark) UNLESS the thread has replies, in which case it
|
||||||
|
* is resolved to preserve the discussion. `hasChildren` selects the branch.
|
||||||
*/
|
*/
|
||||||
describe('CommentService — applySuggestion', () => {
|
describe('CommentService — applySuggestion', () => {
|
||||||
const UPDATED = { id: 'c-1', __updated: true } as any;
|
const UPDATED = { id: 'c-1', __updated: true } as any;
|
||||||
|
|
||||||
function makeService(verdict: unknown) {
|
function makeService(verdict: unknown, hasChildren = false, deletedRows = 1) {
|
||||||
const commentRepo: any = {
|
const commentRepo: any = {
|
||||||
// Both the applied-stamp re-read and resolveComment's re-read go through
|
// Both the applied-stamp re-read and resolveComment's re-read go through
|
||||||
// findById; return a recognizable enriched row.
|
// findById; return a recognizable enriched row.
|
||||||
findById: jest.fn(async () => UPDATED),
|
findById: jest.fn(async () => UPDATED),
|
||||||
updateComment: jest.fn(async () => undefined),
|
updateComment: jest.fn(async () => undefined),
|
||||||
|
hasChildren: jest.fn(async () => hasChildren),
|
||||||
|
deleteComment: jest.fn(async () => undefined),
|
||||||
|
// #338 F1: the childless ephemeral delete is atomic-conditional and
|
||||||
|
// returns the number of rows removed (1 = deleted, 0 = a reply raced in).
|
||||||
|
deleteCommentIfChildless: jest.fn(async () => deletedRows),
|
||||||
};
|
};
|
||||||
const pageRepo: any = {};
|
const pageRepo: any = {};
|
||||||
const wsService: any = { emitCommentEvent: jest.fn() };
|
const wsService: any = { emitCommentEvent: jest.fn() };
|
||||||
@@ -74,7 +84,9 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
.map((c: any[]) => c[0])
|
.map((c: any[]) => c[0])
|
||||||
.find((patch: any) => 'suggestionAppliedAt' in patch);
|
.find((patch: any) => 'suggestionAppliedAt' in patch);
|
||||||
|
|
||||||
it('applied=true → replaces text, persists applied stamps, auto-resolves, audits, returns updated', async () => {
|
// --- no replies → ephemeral delete branch -------------------------------
|
||||||
|
|
||||||
|
it('applied=true, no replies → replaces text, hard-deletes, strips the anchor mark, audits APPLIED, outcome=deleted', async () => {
|
||||||
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||||
makeService({ applied: true, currentText: 'new text' });
|
makeService({ applied: true, currentText: 'new text' });
|
||||||
|
|
||||||
@@ -92,37 +104,34 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Applied stamps persisted.
|
// Ephemeral: the redundant comment is hard-deleted (atomic-conditional) and
|
||||||
const patch = appliedPatch(commentRepo);
|
// its inline anchor mark removed via the deleteCommentMark collab event.
|
||||||
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
expect(patch.suggestionAppliedById).toBe('user-1');
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
|
'deleteCommentMark',
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ commentId: 'c-1', user: expect.any(Object) }),
|
||||||
|
);
|
||||||
|
// No applied stamps are written for a row about to be deleted.
|
||||||
|
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||||
|
|
||||||
// Auto-resolved: resolveComment writes a resolvedAt/resolvedById patch too.
|
// Broadcast a deletion, audit the (still-applied) suggestion, report outcome.
|
||||||
const resolvePatch = commentRepo.updateComment.mock.calls
|
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||||
.map((c: any[]) => c[0])
|
'space-1',
|
||||||
.find((p: any) => 'resolvedAt' in p);
|
'page-1',
|
||||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
|
||||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
);
|
||||||
|
|
||||||
// Audit + broadcast + return.
|
|
||||||
expect(auditService.log).toHaveBeenCalledWith(
|
expect(auditService.log).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||||
resourceType: AuditResource.COMMENT,
|
resourceType: AuditResource.COMMENT,
|
||||||
resourceId: 'c-1',
|
resourceId: 'c-1',
|
||||||
spaceId: 'space-1',
|
|
||||||
metadata: { pageId: 'page-1' },
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
expect(result.outcome).toBe('deleted');
|
||||||
'space-1',
|
|
||||||
'page-1',
|
|
||||||
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
|
||||||
);
|
|
||||||
expect(result).toBe(UPDATED);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applied=false but currentText === suggestedText → idempotent success (no 409)', async () => {
|
it('applied=false but currentText === suggestedText, no replies → idempotent delete (no 409)', async () => {
|
||||||
const { service, commentRepo, auditService } = makeService({
|
const { service, commentRepo, auditService } = makeService({
|
||||||
applied: false,
|
applied: false,
|
||||||
currentText: 'new text',
|
currentText: 'new text',
|
||||||
@@ -130,15 +139,55 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
|
|
||||||
const result = await service.applySuggestion(suggestionComment(), user());
|
const result = await service.applySuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
// The stamps are still persisted (reconciling a crash between the doc
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
// mutation and the DB write) and the call succeeds.
|
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.outcome).toBe('deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- has replies → resolve branch (discussion preserved) ----------------
|
||||||
|
|
||||||
|
it('applied=true, WITH replies → resolves (not delete), persists applied stamps, audits, outcome=resolved', async () => {
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||||
|
makeService({ applied: true, currentText: 'new text' }, true);
|
||||||
|
|
||||||
|
const result = await service.applySuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
// Applied stamps persisted.
|
||||||
const patch = appliedPatch(commentRepo);
|
const patch = appliedPatch(commentRepo);
|
||||||
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
||||||
expect(patch.suggestionAppliedById).toBe('user-1');
|
expect(patch.suggestionAppliedById).toBe('user-1');
|
||||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result).toBe(UPDATED);
|
// Auto-resolved (resolveComment writes the resolve patch + resolve mark).
|
||||||
|
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||||
|
.map((c: any[]) => c[0])
|
||||||
|
.find((p: any) => 'resolvedAt' in p);
|
||||||
|
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||||
|
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||||
|
|
||||||
|
// NOT deleted; broadcast an update, not a deletion.
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||||
|
'deleteCommentMark',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||||
|
'space-1',
|
||||||
|
'page-1',
|
||||||
|
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auditService.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.id).toBe('c-1');
|
||||||
|
expect(result.outcome).toBe('resolved');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- error / rejection branches -----------------------------------------
|
||||||
|
|
||||||
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
|
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
|
||||||
const { service, commentRepo, auditService } = makeService({
|
const { service, commentRepo, auditService } = makeService({
|
||||||
applied: false,
|
applied: false,
|
||||||
@@ -153,14 +202,14 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
expect(err.getResponse()).toMatchObject({
|
expect(err.getResponse()).toMatchObject({
|
||||||
currentText: 'someone else edited this',
|
currentText: 'someone else edited this',
|
||||||
});
|
});
|
||||||
// No persistence and no audit on a conflict.
|
// No delete and no audit on a conflict.
|
||||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
expect(auditService.log).not.toHaveBeenCalled();
|
expect(auditService.log).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('already-applied AND already-resolved → idempotent success, no collab call, no re-resolve (#315 double-click)', async () => {
|
it('already-applied WITH replies → idempotent success, no re-apply, resolve branch', async () => {
|
||||||
const { service, collaborationGateway, commentRepo, auditService } =
|
const { service, collaborationGateway, commentRepo, auditService } =
|
||||||
makeService({ applied: true, currentText: 'new text' });
|
makeService({ applied: true, currentText: 'new text' }, true);
|
||||||
|
|
||||||
const result = await service.applySuggestion(
|
const result = await service.applySuggestion(
|
||||||
suggestionComment({
|
suggestionComment({
|
||||||
@@ -171,17 +220,20 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
user(),
|
user(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Idempotent SUCCESS, not a 409. The suggestion is already applied, so the
|
// Idempotent SUCCESS. The suggestion is already applied, so the document is
|
||||||
// collaborative document is never touched again and nothing is re-stamped
|
// never re-mutated (no applyCommentSuggestion) and nothing is re-stamped.
|
||||||
// or re-resolved.
|
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||||
expect(result).toBe(UPDATED);
|
'applyCommentSuggestion',
|
||||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
expect.anything(),
|
||||||
expect(commentRepo.updateComment).not.toHaveBeenCalled();
|
expect.anything(),
|
||||||
// Same success shape as the applied path (broadcast + audit).
|
);
|
||||||
|
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.outcome).toBe('resolved');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('already-applied but NOT resolved (crash window) → idempotent success, self-heals resolve, no re-apply', async () => {
|
it('already-applied, no replies (double-click after a delete) → deletes idempotently', async () => {
|
||||||
const { service, collaborationGateway, commentRepo } = makeService({
|
const { service, collaborationGateway, commentRepo } = makeService({
|
||||||
applied: true,
|
applied: true,
|
||||||
currentText: 'new text',
|
currentText: 'new text',
|
||||||
@@ -192,28 +244,43 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
user(),
|
user(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(UPDATED);
|
// No re-apply to the document; the childless applied comment is removed.
|
||||||
|
|
||||||
// The suggestion is NOT re-applied to the document…
|
|
||||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||||
'applyCommentSuggestion',
|
'applyCommentSuggestion',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
// …but the open thread is self-healed to resolved via resolveComment, which
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
// writes the resolve patch and updates the resolve mark.
|
expect(result.outcome).toBe('deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applied=true, no replies at read time but a reply races in (conditional delete → 0 rows) → resolves instead, no hard-delete, outcome=resolved (#338 F1)', async () => {
|
||||||
|
// The suggested text is already applied to the document, but between the
|
||||||
|
// hasChildren read and the atomic delete a reply landed. The parent must NOT
|
||||||
|
// be hard-deleted (cascade would destroy the reply); resolve the thread.
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway } =
|
||||||
|
makeService({ applied: true, currentText: 'new text' }, false, 0);
|
||||||
|
|
||||||
|
const result = await service.applySuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
|
// No deletion broadcast — the row + the racing reply survive.
|
||||||
|
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ operation: 'commentDeleted' }),
|
||||||
|
);
|
||||||
|
// Fell back to resolving.
|
||||||
const resolvePatch = commentRepo.updateComment.mock.calls
|
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||||
.map((c: any[]) => c[0])
|
.map((c: any[]) => c[0])
|
||||||
.find((p: any) => 'resolvedAt' in p);
|
.find((p: any) => 'resolvedAt' in p);
|
||||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
|
||||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
'resolveCommentMark',
|
'resolveCommentMark',
|
||||||
'page.page-1',
|
'page.page-1',
|
||||||
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||||
);
|
);
|
||||||
// The applied stamps are NOT re-written (already stamped).
|
expect(result.outcome).toBe('resolved');
|
||||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a comment with no suggestedText', async () => {
|
it('rejects a comment with no suggestedText', async () => {
|
||||||
@@ -238,8 +305,8 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
service.applySuggestion(suggestionComment(), user()),
|
service.applySuggestion(suggestionComment(), user()),
|
||||||
).rejects.toThrow(InternalServerErrorException);
|
).rejects.toThrow(InternalServerErrorException);
|
||||||
|
|
||||||
// Nothing persisted, nothing audited.
|
// Nothing deleted, nothing audited.
|
||||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
expect(auditService.log).not.toHaveBeenCalled();
|
expect(auditService.log).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { CommentService } from './comment.service';
|
||||||
|
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for CommentService.dismissSuggestion (#329). Dismiss ("Не применять")
|
||||||
|
* removes a suggested edit WITHOUT changing the page text: the comment
|
||||||
|
* disappears (hard-delete + strip the inline anchor mark) unless the thread has
|
||||||
|
* replies, in which case it is resolved to preserve the discussion.
|
||||||
|
*
|
||||||
|
* The permission gate (canComment, NOT canEdit) lives in the controller and is
|
||||||
|
* covered in comment.controller.spec.ts; here we pin the service's own state
|
||||||
|
* guards and the delete-vs-resolve fork.
|
||||||
|
*/
|
||||||
|
describe('CommentService — dismissSuggestion', () => {
|
||||||
|
const UPDATED = { id: 'c-1', __updated: true } as any;
|
||||||
|
|
||||||
|
function makeService(hasChildren = false, deletedRows = 1) {
|
||||||
|
const commentRepo: any = {
|
||||||
|
findById: jest.fn(async () => UPDATED),
|
||||||
|
updateComment: jest.fn(async () => undefined),
|
||||||
|
hasChildren: jest.fn(async () => hasChildren),
|
||||||
|
deleteComment: jest.fn(async () => undefined),
|
||||||
|
// #338 F1: the childless ephemeral delete is now atomic-conditional and
|
||||||
|
// returns the number of rows removed (1 = deleted, 0 = a reply raced in).
|
||||||
|
deleteCommentIfChildless: jest.fn(async () => deletedRows),
|
||||||
|
};
|
||||||
|
const pageRepo: any = {};
|
||||||
|
const wsService: any = { emitCommentEvent: jest.fn() };
|
||||||
|
const collaborationGateway: any = {
|
||||||
|
handleYjsEvent: jest.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
|
||||||
|
const notificationQueue: any = { add: jest.fn(async () => undefined) };
|
||||||
|
const auditService: any = { log: jest.fn() };
|
||||||
|
|
||||||
|
const service = new CommentService(
|
||||||
|
commentRepo,
|
||||||
|
pageRepo,
|
||||||
|
wsService,
|
||||||
|
collaborationGateway,
|
||||||
|
generalQueue,
|
||||||
|
notificationQueue,
|
||||||
|
auditService,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, commentRepo, wsService, collaborationGateway, auditService };
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestionComment = (over?: Partial<any>): any => ({
|
||||||
|
id: 'c-1',
|
||||||
|
pageId: 'page-1',
|
||||||
|
spaceId: 'space-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
creatorId: 'user-1',
|
||||||
|
parentCommentId: null,
|
||||||
|
selection: 'old text',
|
||||||
|
suggestedText: 'new text',
|
||||||
|
suggestionAppliedAt: null,
|
||||||
|
resolvedAt: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
|
||||||
|
|
||||||
|
it('no replies → hard-deletes, strips the anchor mark, does NOT touch page text, audits DISMISSED, outcome=deleted', async () => {
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||||
|
makeService(false);
|
||||||
|
|
||||||
|
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
// Never applies the suggestion to the document.
|
||||||
|
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||||
|
'applyCommentSuggestion',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
// Hard-delete (atomic-conditional) + strip mark.
|
||||||
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
|
'deleteCommentMark',
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ commentId: 'c-1', user: expect.any(Object) }),
|
||||||
|
);
|
||||||
|
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||||
|
'space-1',
|
||||||
|
'page-1',
|
||||||
|
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
|
||||||
|
);
|
||||||
|
expect(auditService.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||||
|
resourceType: AuditResource.COMMENT,
|
||||||
|
resourceId: 'c-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.outcome).toBe('deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no replies → if the anchor-mark removal FAILS, the row is NOT deleted and the error propagates (#329: no orphan anchor)', async () => {
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway } =
|
||||||
|
makeService(false);
|
||||||
|
// Mark removal is FATAL and runs BEFORE the irreversible row delete: a collab
|
||||||
|
// failure (e.g. COLLAB_DISABLE_REDIS "no live instance") must abort the whole
|
||||||
|
// operation, leaving row + mark consistent — never a deleted row with an
|
||||||
|
// orphan anchor left in the document reporting success.
|
||||||
|
collaborationGateway.handleYjsEvent = jest.fn(async () => {
|
||||||
|
throw new Error('requires a live collaboration instance');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(suggestionComment(), user()),
|
||||||
|
).rejects.toThrow(/live collaboration/);
|
||||||
|
|
||||||
|
expect(commentRepo.deleteCommentIfChildless).not.toHaveBeenCalled();
|
||||||
|
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ operation: 'commentDeleted' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WITH replies → resolves (not delete), does NOT apply, audits DISMISSED, outcome=resolved', async () => {
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||||
|
makeService(true);
|
||||||
|
|
||||||
|
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
// Resolved via resolveComment (resolve patch + resolve mark), NOT deleted.
|
||||||
|
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||||
|
.map((c: any[]) => c[0])
|
||||||
|
.find((p: any) => 'resolvedAt' in p);
|
||||||
|
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||||
|
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
|
'resolveCommentMark',
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||||
|
);
|
||||||
|
// No applied stamp — dismiss does not apply the edit.
|
||||||
|
const appliedPatch = commentRepo.updateComment.mock.calls
|
||||||
|
.map((c: any[]) => c[0])
|
||||||
|
.find((p: any) => 'suggestionAppliedAt' in p);
|
||||||
|
expect(appliedPatch).toBeUndefined();
|
||||||
|
|
||||||
|
expect(auditService.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.outcome).toBe('resolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reply races in after the childless read (conditional delete → 0 rows) → resolves instead, does NOT hard-delete, reply survives, outcome=resolved (#338 F1)', async () => {
|
||||||
|
// hasChildren=false selects the ephemeral branch (the read saw no replies),
|
||||||
|
// but the atomic delete matches 0 rows because a reply landed in the window
|
||||||
|
// between that read and the delete. The parent must NOT be hard-deleted
|
||||||
|
// (a cascade would destroy the just-added reply); the thread is resolved.
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway } =
|
||||||
|
makeService(false, 0);
|
||||||
|
|
||||||
|
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
// The conditional delete was attempted (and matched nothing).
|
||||||
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
|
// No commentDeleted broadcast — the row (and the racing reply) survive.
|
||||||
|
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ operation: 'commentDeleted' }),
|
||||||
|
);
|
||||||
|
// Fell back to resolving the thread.
|
||||||
|
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||||
|
.map((c: any[]) => c[0])
|
||||||
|
.find((p: any) => 'resolvedAt' in p);
|
||||||
|
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||||
|
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||||
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
|
'resolveCommentMark',
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||||
|
);
|
||||||
|
expect(result.outcome).toBe('resolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a reply (non-top-level) comment', async () => {
|
||||||
|
const { service, commentRepo } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(
|
||||||
|
suggestionComment({ parentCommentId: 'parent-1' }),
|
||||||
|
user(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a comment without a suggested edit', async () => {
|
||||||
|
const { service, commentRepo } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(
|
||||||
|
suggestionComment({ suggestedText: null }),
|
||||||
|
user(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an already-applied suggestion', async () => {
|
||||||
|
const { service, commentRepo } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(
|
||||||
|
suggestionComment({ suggestionAppliedAt: new Date() }),
|
||||||
|
user(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an already-resolved thread', async () => {
|
||||||
|
const { service, commentRepo } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(
|
||||||
|
suggestionComment({ resolvedAt: new Date() }),
|
||||||
|
user(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,12 @@ import {
|
|||||||
IAuditService,
|
IAuditService,
|
||||||
} from '../../integrations/audit/audit.service';
|
} from '../../integrations/audit/audit.service';
|
||||||
|
|
||||||
|
// Ephemeral-suggestion settle result (#329): 'deleted' → the comment vanished
|
||||||
|
// (hard-delete + anchor mark stripped); 'resolved' → the thread had replies and
|
||||||
|
// was resolved instead. Returned to the client so it can pick the optimistic
|
||||||
|
// cache action.
|
||||||
|
export type SuggestionOutcome = 'deleted' | 'resolved';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService {
|
export class CommentService {
|
||||||
private readonly logger = new Logger(CommentService.name);
|
private readonly logger = new Logger(CommentService.name);
|
||||||
@@ -362,7 +368,7 @@ export class CommentService {
|
|||||||
comment: Comment,
|
comment: Comment,
|
||||||
user: User,
|
user: User,
|
||||||
provenance?: AuthProvenanceData,
|
provenance?: AuthProvenanceData,
|
||||||
): Promise<Comment> {
|
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||||
// Structural guards.
|
// Structural guards.
|
||||||
if (comment.parentCommentId) {
|
if (comment.parentCommentId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -449,42 +455,148 @@ export class CommentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist the applied stamps (idempotently), auto-resolve the thread and
|
* Dismiss ("Не применять") a suggested edit without touching the page text:
|
||||||
* broadcast + audit the applied suggestion. Shared by the applied and the
|
* the suggestion disappears. Ephemeral rule (#329) — a top-level suggestion
|
||||||
|
* comment is transient UI, so dismissing it hard-deletes the comment AND strips
|
||||||
|
* its inline anchor mark UNLESS the thread has replies, in which case the
|
||||||
|
* discussion is preserved by resolving it instead.
|
||||||
|
*
|
||||||
|
* Dismiss does NOT change the document text, so the controller authorizes it
|
||||||
|
* with canComment (NOT canEdit). This re-checks the comment's own state so the
|
||||||
|
* invariant holds regardless of caller.
|
||||||
|
*/
|
||||||
|
async dismissSuggestion(
|
||||||
|
comment: Comment,
|
||||||
|
user: User,
|
||||||
|
provenance?: AuthProvenanceData,
|
||||||
|
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||||
|
// Structural guards (mirror applySuggestion).
|
||||||
|
if (comment.parentCommentId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Only a top-level comment can carry a suggested edit',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!comment.suggestedText) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This comment has no suggested edit to dismiss',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// State guards: dismissing an already-applied or already-resolved thread is
|
||||||
|
// meaningless. On an apply↔dismiss race the loser sees the comment already
|
||||||
|
// gone (404 at the controller) or already resolved (this 400); the client
|
||||||
|
// treats both as "already resolved".
|
||||||
|
if (comment.suggestionAppliedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot dismiss a suggested edit that was already applied',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (comment.resolvedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot dismiss a suggested edit on a resolved comment thread',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChildren = await this.commentRepo.hasChildren(comment.id);
|
||||||
|
|
||||||
|
if (hasChildren) {
|
||||||
|
// Preserve the discussion: resolve (never delete) a thread with replies.
|
||||||
|
const updatedComment = await this.resolveComment(
|
||||||
|
comment,
|
||||||
|
true,
|
||||||
|
user,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
this.auditService.log({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||||
|
resourceType: AuditResource.COMMENT,
|
||||||
|
resourceId: comment.id,
|
||||||
|
spaceId: comment.spaceId,
|
||||||
|
metadata: { pageId: comment.pageId },
|
||||||
|
});
|
||||||
|
return { ...updatedComment, outcome: 'resolved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ephemeral: no replies → the suggestion vanishes entirely. The atomic
|
||||||
|
// conditional delete may still fall back to a resolve if a reply raced in
|
||||||
|
// (see deleteEphemeralSuggestion), so the outcome is whatever it settled on.
|
||||||
|
const settled = await this.deleteEphemeralSuggestion(comment, user, provenance);
|
||||||
|
this.auditService.log({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||||
|
resourceType: AuditResource.COMMENT,
|
||||||
|
resourceId: comment.id,
|
||||||
|
spaceId: comment.spaceId,
|
||||||
|
metadata: { pageId: comment.pageId },
|
||||||
|
});
|
||||||
|
return settled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the applied stamps (idempotently), then settle the suggestion under
|
||||||
|
* the ephemeral rule (#329): a suggestion whose thread has NO replies
|
||||||
|
* DISAPPEARS after apply (hard-delete + strip the inline anchor mark), since
|
||||||
|
* the suggested text is now in the document and a stand-alone resolved thread
|
||||||
|
* would only pile up an orphan anchor. A thread WITH replies is preserved by
|
||||||
|
* auto-resolving it (the historical behaviour). Shared by the applied and the
|
||||||
* idempotent "already-applied" branches of applySuggestion.
|
* idempotent "already-applied" branches of applySuggestion.
|
||||||
|
*
|
||||||
|
* Returns the comment augmented with `outcome` so the client can pick the
|
||||||
|
* optimistic action ('deleted' → drop it, 'resolved' → move to the resolved
|
||||||
|
* tab).
|
||||||
*/
|
*/
|
||||||
private async finalizeAppliedSuggestion(
|
private async finalizeAppliedSuggestion(
|
||||||
comment: Comment,
|
comment: Comment,
|
||||||
user: User,
|
user: User,
|
||||||
provenance?: AuthProvenanceData,
|
provenance?: AuthProvenanceData,
|
||||||
): Promise<Comment> {
|
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||||
if (!comment.suggestionAppliedAt) {
|
const hasChildren = await this.commentRepo.hasChildren(comment.id);
|
||||||
await this.commentRepo.updateComment(
|
|
||||||
{
|
if (hasChildren) {
|
||||||
suggestionAppliedAt: new Date(),
|
// Thread has replies → preserve the discussion: stamp applied + resolve.
|
||||||
suggestionAppliedById: user.id,
|
if (!comment.suggestionAppliedAt) {
|
||||||
},
|
await this.commentRepo.updateComment(
|
||||||
comment.id,
|
{
|
||||||
);
|
suggestionAppliedAt: new Date(),
|
||||||
|
suggestionAppliedById: user.id,
|
||||||
|
},
|
||||||
|
comment.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
|
||||||
|
// broadcast and the resolve notification. Stay defensive on re-entry.
|
||||||
|
if (!comment.resolvedAt) {
|
||||||
|
await this.resolveComment(comment, true, user, provenance);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedComment = await this.commentRepo.findById(comment.id, {
|
||||||
|
includeCreator: true,
|
||||||
|
includeResolvedBy: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||||
|
operation: 'commentUpdated',
|
||||||
|
pageId: comment.pageId,
|
||||||
|
comment: updatedComment,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.auditService.log({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||||
|
resourceType: AuditResource.COMMENT,
|
||||||
|
resourceId: comment.id,
|
||||||
|
spaceId: comment.spaceId,
|
||||||
|
metadata: { pageId: comment.pageId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...updatedComment, outcome: 'resolved' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
|
// No replies → ephemeral: the suggested text is already in the document, so
|
||||||
// broadcast and the resolve notification. The guard above guarantees the
|
// the comment is redundant. Hard-delete it and strip its inline anchor. We
|
||||||
// thread was open when we entered, but stay defensive on re-entry.
|
// deliberately do NOT write the applied stamps first (the row is about to be
|
||||||
if (!comment.resolvedAt) {
|
// deleted); the audit event still records that the suggestion was applied.
|
||||||
await this.resolveComment(comment, true, user, provenance);
|
// The delete is atomic-conditional: if a reply raced in after the
|
||||||
}
|
// hasChildren read, it falls back to resolving instead (outcome 'resolved').
|
||||||
|
const settled = await this.deleteEphemeralSuggestion(comment, user, provenance);
|
||||||
const updatedComment = await this.commentRepo.findById(comment.id, {
|
|
||||||
includeCreator: true,
|
|
||||||
includeResolvedBy: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
|
||||||
operation: 'commentUpdated',
|
|
||||||
pageId: comment.pageId,
|
|
||||||
comment: updatedComment,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.auditService.log({
|
this.auditService.log({
|
||||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||||
@@ -494,7 +606,86 @@ export class CommentService {
|
|||||||
metadata: { pageId: comment.pageId },
|
metadata: { pageId: comment.pageId },
|
||||||
});
|
});
|
||||||
|
|
||||||
return updatedComment;
|
return settled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle an ephemeral suggestion whose thread looked childless: remove its
|
||||||
|
* inline `comment` anchor mark, then ATOMICALLY hard-delete the row only if it
|
||||||
|
* is still childless. Shared by the apply/dismiss no-replies branches (#329).
|
||||||
|
*
|
||||||
|
* ORDER MATTERS: the anchor mark is removed FIRST and FATALLY (mirrors
|
||||||
|
* applySuggestion, which mutates the doc before writing the DB). The row
|
||||||
|
* delete is irreversible, so if the mark removal fails — including the
|
||||||
|
* COLLAB_DISABLE_REDIS "no live instance" hard-error — we must NOT delete the
|
||||||
|
* row and report success, or the document is left with a permanent orphan
|
||||||
|
* anchor pointing at a comment that no longer exists (the exact data-integrity
|
||||||
|
* bug #329 targets). Let the exception propagate (→ 5xx); the operation is
|
||||||
|
* then repeatable with row + mark still consistent.
|
||||||
|
*
|
||||||
|
* RACE (#338 F4): the caller read `hasChildren` BEFORE the (slow) mark
|
||||||
|
* removal, so a reply can land in that window. `comments.parent_comment_id` is
|
||||||
|
* ON DELETE CASCADE, so an unconditional delete here would cascade-destroy the
|
||||||
|
* just-added reply forever. Instead we use `deleteCommentIfChildless`, which
|
||||||
|
* re-checks childlessness under a FOR UPDATE lock inside a transaction (a plain
|
||||||
|
* anti-join DELETE is NOT race-safe under READ COMMITTED — see the repo method
|
||||||
|
* docstring). If it removes the row (outcome 'deleted') we broadcast the
|
||||||
|
* deletion as before. If it removes 0 rows (a reply interleaved) we do NOT
|
||||||
|
* hard-delete — we resolve the thread instead (outcome 'resolved'), preserving
|
||||||
|
* the discussion and the new reply. The anchor mark is already gone by then, an
|
||||||
|
* accepted degradation: the thread lands in the resolved tab without its inline
|
||||||
|
* highlight — far better than losing a reply.
|
||||||
|
*/
|
||||||
|
private async deleteEphemeralSuggestion(
|
||||||
|
comment: Comment,
|
||||||
|
user: User,
|
||||||
|
provenance?: AuthProvenanceData,
|
||||||
|
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||||
|
await this.deleteCommentMark(comment, user);
|
||||||
|
|
||||||
|
const deletedRows = await this.commentRepo.deleteCommentIfChildless(
|
||||||
|
comment.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deletedRows > 0) {
|
||||||
|
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||||
|
operation: 'commentDeleted',
|
||||||
|
pageId: comment.pageId,
|
||||||
|
commentId: comment.id,
|
||||||
|
});
|
||||||
|
return { ...comment, outcome: 'deleted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reply interleaved between the hasChildren read and this delete, so the
|
||||||
|
// conditional delete matched nothing. Preserve the discussion + the new
|
||||||
|
// reply by resolving the thread instead of hard-deleting it. resolveComment
|
||||||
|
// handles the resolve patch, its ws broadcast and the resolve notification;
|
||||||
|
// its collab call is best-effort, so the already-stripped mark is fine.
|
||||||
|
const resolvedComment = await this.resolveComment(
|
||||||
|
comment,
|
||||||
|
true,
|
||||||
|
user,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
return { ...resolvedComment, outcome: 'resolved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the inline `comment` mark for a comment from the collaborative
|
||||||
|
* document. FATAL, NOT best-effort: unlike resolveComment (which keeps the row,
|
||||||
|
* so a failed mark update is recoverable), this is used before an irreversible
|
||||||
|
* hard-delete, so the mark removal MUST succeed or throw. Under
|
||||||
|
* COLLAB_DISABLE_REDIS the gateway invokes the deleteCommentMark handler
|
||||||
|
* directly (never a silent no-op) and a missing live instance surfaces as a
|
||||||
|
* thrown error, which we let propagate so the caller aborts before deleting.
|
||||||
|
*/
|
||||||
|
private async deleteCommentMark(comment: Comment, user: User): Promise<void> {
|
||||||
|
const documentName = `page.${comment.pageId}`;
|
||||||
|
await this.collaborationGateway.handleYjsEvent(
|
||||||
|
'deleteCommentMark',
|
||||||
|
documentName,
|
||||||
|
{ commentId: comment.id, user },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async queueCommentNotification(
|
private async queueCommentNotification(
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class DismissSuggestionDto {
|
||||||
|
@IsUUID()
|
||||||
|
commentId: string;
|
||||||
|
}
|
||||||
@@ -139,6 +139,65 @@ export class CommentRepo {
|
|||||||
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
|
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an ephemeral suggestion row ONLY if it is still childless, returning
|
||||||
|
* the number of rows removed (0 or 1). Closes the data-loss race in
|
||||||
|
* dismiss/apply (#338 F4): the service reads `hasChildren`, then removes the
|
||||||
|
* anchor mark (a collab round-trip of tens-to-hundreds of ms), then calls this.
|
||||||
|
* `comments.parent_comment_id` is ON DELETE CASCADE, so a reply landing in that
|
||||||
|
* window would be cascade-destroyed by a blind delete.
|
||||||
|
*
|
||||||
|
* A single anti-join `DELETE … WHERE NOT EXISTS(child)` is NOT sufficient under
|
||||||
|
* READ COMMITTED: if a reply INSERT (holding FOR KEY SHARE on the parent, not
|
||||||
|
* yet committed) interleaves, the DELETE's snapshot does not see the
|
||||||
|
* uncommitted child, so `NOT EXISTS` is true and the parent qualifies; the
|
||||||
|
* DELETE then blocks on the child's key-share lock, and when it wakes the row
|
||||||
|
* was only LOCKED (not modified), so EvalPlanQual does NOT re-evaluate the
|
||||||
|
* predicate → the parent is deleted and the just-committed reply cascades away.
|
||||||
|
*
|
||||||
|
* So we do a lock-then-recheck in ONE transaction:
|
||||||
|
* 1. `SELECT id … FOR UPDATE` on the parent. FOR UPDATE conflicts with the
|
||||||
|
* FOR KEY SHARE a concurrent reply INSERT takes on its parent (FK), so a
|
||||||
|
* reply in the window serializes against us: it either commits before we
|
||||||
|
* acquire the lock, or it must wait until this tx ends.
|
||||||
|
* 2. Re-read childlessness with a FRESH statement in the SAME tx. Under RC a
|
||||||
|
* new statement gets a new snapshot, so a reply that committed while we
|
||||||
|
* waited on the lock is now visible.
|
||||||
|
* 3. Delete only if still childless (return 1); otherwise return 0 so the
|
||||||
|
* caller resolves the thread instead. The FOR UPDATE lock is held to
|
||||||
|
* end-of-tx, so no new reply can insert between the re-check and the delete.
|
||||||
|
*/
|
||||||
|
async deleteCommentIfChildless(commentId: string): Promise<number> {
|
||||||
|
return this.db.transaction().execute(async (trx) => {
|
||||||
|
const parent = await trx
|
||||||
|
.selectFrom('comments')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', commentId)
|
||||||
|
.forUpdate()
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
// Already gone (e.g. a racing delete won) → nothing to remove.
|
||||||
|
if (!parent) return 0;
|
||||||
|
|
||||||
|
const child = await trx
|
||||||
|
.selectFrom('comments')
|
||||||
|
.select('id')
|
||||||
|
.where('parentCommentId', '=', commentId)
|
||||||
|
.limit(1)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
// A reply exists (possibly one that just committed) → do NOT hard-delete;
|
||||||
|
// the cascade would destroy it. Caller falls back to resolving the thread.
|
||||||
|
if (child) return 0;
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.deleteFrom('comments')
|
||||||
|
.where('id', '=', commentId)
|
||||||
|
.execute();
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async hasChildren(commentId: string): Promise<boolean> {
|
async hasChildren(commentId: string): Promise<boolean> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.selectFrom('comments')
|
.selectFrom('comments')
|
||||||
|
|||||||
@@ -261,6 +261,21 @@ export class EnvironmentService {
|
|||||||
return disable === 'true';
|
return disable === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deferred tool loading for the in-app AI chat (#332). When enabled, the agent
|
||||||
|
* sees a compact <tool_catalog> and only CORE tools + the loadTools meta-tool
|
||||||
|
* are active each step; deferred tools (the fat/rare ones + all external MCP
|
||||||
|
* tools) load on demand. Defaults to ENABLED — the issue treats deferred
|
||||||
|
* loading as the new behavior; set AI_CHAT_DEFERRED_TOOLS=false to restore the
|
||||||
|
* old "all tools always active" behavior.
|
||||||
|
*/
|
||||||
|
isAiChatDeferredToolsEnabled(): boolean {
|
||||||
|
const enabled = this.configService
|
||||||
|
.get<string>('AI_CHAT_DEFERRED_TOOLS', 'true')
|
||||||
|
.toLowerCase();
|
||||||
|
return enabled === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
getPostHogHost(): string {
|
getPostHogHost(): string {
|
||||||
return this.configService.get<string>('POSTHOG_HOST');
|
return this.configService.get<string>('POSTHOG_HOST');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
|
import { tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
|
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
|
||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
@@ -146,6 +148,9 @@ describe('AiChatService.stream [integration]', () => {
|
|||||||
{} as any, // aiAgentRoleRepo (role is pre-resolved + passed in)
|
{} as any, // aiAgentRoleRepo (role is pre-resolved + passed in)
|
||||||
{} as any, // pageRepo (only used when body.openPage is set)
|
{} as any, // pageRepo (only used when body.openPage is set)
|
||||||
{} as any, // pageAccess (idem)
|
{} as any, // pageAccess (idem)
|
||||||
|
// environment (#332): keep deferred tool loading OFF for this lifecycle
|
||||||
|
// harness so the toolset/behavior is exactly as before.
|
||||||
|
{ isAiChatDeferredToolsEnabled: () => false } as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,4 +320,174 @@ describe('AiChatService.stream [integration]', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #332 deferred tool loading, the ON path. The riskiest property is that the
|
||||||
|
* per-turn `activatedTools` Set is created FRESH inside each stream() call, so a
|
||||||
|
* tool a previous turn activated via loadTools is NOT still active when the next
|
||||||
|
* turn starts — the new turn begins "cold" (CORE + loadTools only). The unit
|
||||||
|
* tests only exercise pure prepareAgentStep with hand-fed Sets; this pins the
|
||||||
|
* real wiring end-to-end (loadTools.execute -> activatedTools -> prepareStep ->
|
||||||
|
* per-step activeTools) against the real streamText loop, and proves there is no
|
||||||
|
* cross-turn leak. We drive a MockLanguageModelV3 whose step 1 calls
|
||||||
|
* loadTools(['createPage']) and assert, via the model's recorded per-step
|
||||||
|
* CallOptions.tools (the AI SDK filters the provider tool list by activeTools),
|
||||||
|
* that the deferred tool becomes active on the SAME turn's next step but NOT on a
|
||||||
|
* fresh turn's first step.
|
||||||
|
*/
|
||||||
|
describe('deferred tool loading ON — per-turn activation, no leak (#332)', () => {
|
||||||
|
// A stub deferred (non-core) tool the agent can activate. Its execute is never
|
||||||
|
// called — the model only needs to SEE it become active — but it must be a
|
||||||
|
// valid AI-SDK tool so the SDK includes it in a step's tool list once active.
|
||||||
|
const createPageStub = tool({
|
||||||
|
description: 'create a new page',
|
||||||
|
inputSchema: z.object({ title: z.string() }),
|
||||||
|
execute: async () => ({ id: 'p-stub' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// A CORE tool in the toolset, so a cold step shows CORE tools ARE active while
|
||||||
|
// the deferred createPage is not. `searchPages` is in CORE_TOOL_SET.
|
||||||
|
const searchPagesStub = tool({
|
||||||
|
description: 'search the wiki',
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same lifecycle harness as buildService() above, but with deferred loading ON
|
||||||
|
// and a toolset that exposes exactly one deferred tool (createPage) so it is
|
||||||
|
// catalogued + loadable-by-name. Kept separate so the OFF scenarios are
|
||||||
|
// untouched.
|
||||||
|
function buildDeferredService(): AiChatService {
|
||||||
|
return new AiChatService(
|
||||||
|
{ getChatModel: async () => null } as any,
|
||||||
|
aiChatRepo,
|
||||||
|
msgRepo,
|
||||||
|
{} as any,
|
||||||
|
{ resolve: async () => null } as any,
|
||||||
|
{
|
||||||
|
forUser: async () => ({
|
||||||
|
searchPages: searchPagesStub,
|
||||||
|
createPage: createPageStub,
|
||||||
|
}),
|
||||||
|
getInAppDeferredCatalog: async () => [
|
||||||
|
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
mcpClients as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
// #332: deferred tool loading ON — the property under test.
|
||||||
|
{ isAiChatDeferredToolsEnabled: () => true } as any,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive ONE stream() turn against `model` and wait for the assistant row to
|
||||||
|
// settle (mirrors runStream, but builds the deferred-ON service).
|
||||||
|
async function runDeferredTurn(
|
||||||
|
model: MockLanguageModelV3,
|
||||||
|
chatId: string,
|
||||||
|
body: any,
|
||||||
|
): Promise<void> {
|
||||||
|
closeCalls = 0;
|
||||||
|
const service = buildDeferredService();
|
||||||
|
const { res, cleanup } = await makeRealResponse();
|
||||||
|
try {
|
||||||
|
await service.stream({
|
||||||
|
user: { id: userId, workspaceId } as any,
|
||||||
|
workspace: { id: workspaceId, name: 'WS' } as any,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body,
|
||||||
|
res: { raw: res } as any,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: model as any,
|
||||||
|
role: null,
|
||||||
|
} as any);
|
||||||
|
await waitFor(async () => {
|
||||||
|
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
|
||||||
|
return rows.some(
|
||||||
|
(r) =>
|
||||||
|
r.role === 'assistant' &&
|
||||||
|
['completed', 'error', 'aborted'].includes(r.status as string),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitFor(() => closeCalls > 0, { timeoutMs: 5_000 });
|
||||||
|
} finally {
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool names the provider actually received for a recorded step (activeTools
|
||||||
|
// filters this list, so it reflects what was active that step).
|
||||||
|
const toolNames = (call: any): string[] =>
|
||||||
|
((call?.tools ?? []) as any[]).map((t) => t?.name).filter(Boolean);
|
||||||
|
|
||||||
|
// A model that, on step 1, calls loadTools(['createPage']); on step 2, answers.
|
||||||
|
function loadThenAnswerModel(): MockLanguageModelV3 {
|
||||||
|
let step = 0;
|
||||||
|
return new MockLanguageModelV3({
|
||||||
|
doStream: async () => {
|
||||||
|
const n = step++;
|
||||||
|
if (n === 0) {
|
||||||
|
return {
|
||||||
|
stream: convertArrayToReadableStream([
|
||||||
|
{ type: 'stream-start', warnings: [] },
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: 'lt1',
|
||||||
|
toolName: 'loadTools',
|
||||||
|
input: JSON.stringify({ names: ['createPage'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'finish',
|
||||||
|
finishReason: 'tool-calls',
|
||||||
|
usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 },
|
||||||
|
},
|
||||||
|
] as any),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { stream: successStream() };
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('activates a deferred tool for the SAME turn, and a NEW turn starts cold (no leak)', async () => {
|
||||||
|
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||||
|
|
||||||
|
// --- Turn 1: loadTools(createPage) on step 1, then answer on step 2. ---
|
||||||
|
const model1 = loadThenAnswerModel();
|
||||||
|
await runDeferredTurn(model1, chatId, {
|
||||||
|
chatId,
|
||||||
|
messages: [userUiMessage('Make me a page')],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The turn ran at least two steps (the load round-trip + the answer).
|
||||||
|
expect(model1.doStreamCalls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const step1Tools = toolNames(model1.doStreamCalls[0]);
|
||||||
|
const step2Tools = toolNames(model1.doStreamCalls[1]);
|
||||||
|
|
||||||
|
// Step 1 starts cold: CORE tools + the loadTools meta-tool are active, but
|
||||||
|
// the deferred createPage is NOT yet.
|
||||||
|
expect(step1Tools).toContain('loadTools');
|
||||||
|
expect(step1Tools).toContain('searchPages'); // a CORE tool, always active
|
||||||
|
expect(step1Tools).not.toContain('createPage');
|
||||||
|
// Step 2 of the SAME turn sees the just-activated deferred tool.
|
||||||
|
expect(step2Tools).toContain('createPage');
|
||||||
|
|
||||||
|
// --- Turn 2 on the SAME chat: must start cold again. ---
|
||||||
|
const model2 = new MockLanguageModelV3({
|
||||||
|
doStream: async () => ({ stream: successStream() }),
|
||||||
|
} as any);
|
||||||
|
await runDeferredTurn(model2, chatId, {
|
||||||
|
chatId,
|
||||||
|
messages: [userUiMessage('And another thing')],
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextTurnFirstStep = toolNames(model2.doStreamCalls[0]);
|
||||||
|
expect(nextTurnFirstStep).toContain('loadTools');
|
||||||
|
// The activated set is per-turn: the prior turn's createPage did NOT leak,
|
||||||
|
// so the fresh turn's first step sees it deferred again.
|
||||||
|
expect(nextTurnFirstStep).not.toContain('createPage');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { CommentRepo } from '../../src/database/repos/comment/comment.repo';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
buildTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createSpace,
|
||||||
|
createPage,
|
||||||
|
createUser,
|
||||||
|
createComment,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-DB coverage for CommentRepo.deleteCommentIfChildless (#338 F4/F6).
|
||||||
|
*
|
||||||
|
* This is the guard that keeps an ephemeral-suggestion hard-delete from
|
||||||
|
* cascade-destroying a reply (`comments.parent_comment_id` is ON DELETE CASCADE).
|
||||||
|
* The unit tests MOCK this method to 0/1, so only an int-spec actually exercises
|
||||||
|
* the SQL — the FOR UPDATE lock-then-recheck transaction — against Postgres.
|
||||||
|
*
|
||||||
|
* The concurrency case is the whole point: a plain anti-join
|
||||||
|
* `DELETE … WHERE NOT EXISTS(child)` passes (a) and (b) but SILENTLY loses a
|
||||||
|
* reply that commits mid-operation under READ COMMITTED (EvalPlanQual does not
|
||||||
|
* re-check a merely-locked row). Test (c) reproduces exactly that interleaving
|
||||||
|
* and asserts the row + reply both survive.
|
||||||
|
*/
|
||||||
|
describe('CommentRepo.deleteCommentIfChildless [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let repo: CommentRepo;
|
||||||
|
let workspaceId: string;
|
||||||
|
let spaceId: string;
|
||||||
|
let pageId: string;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
repo = new CommentRepo(db as any);
|
||||||
|
workspaceId = (await createWorkspace(db)).id;
|
||||||
|
spaceId = (await createSpace(db, workspaceId)).id;
|
||||||
|
pageId = (await createPage(db, { workspaceId, spaceId })).id;
|
||||||
|
userId = (await createUser(db, workspaceId)).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function rowExists(id: string): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('comments')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedTopLevel() {
|
||||||
|
return createComment(db, {
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
creatorId: userId,
|
||||||
|
selection: 'old text',
|
||||||
|
suggestedText: 'new text',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedReply(parentId: string) {
|
||||||
|
return createComment(db, {
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
creatorId: userId,
|
||||||
|
parentCommentId: parentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('(a) childless top-level → returns 1 and the row is gone', async () => {
|
||||||
|
const parent = await seedTopLevel();
|
||||||
|
expect(await rowExists(parent.id)).toBe(true);
|
||||||
|
|
||||||
|
const deleted = await repo.deleteCommentIfChildless(parent.id);
|
||||||
|
|
||||||
|
expect(deleted).toBe(1);
|
||||||
|
expect(await rowExists(parent.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(b) top-level WITH a committed reply → returns 0, parent AND reply survive (gate blocks the cascade)', async () => {
|
||||||
|
const parent = await seedTopLevel();
|
||||||
|
const reply = await seedReply(parent.id);
|
||||||
|
|
||||||
|
const deleted = await repo.deleteCommentIfChildless(parent.id);
|
||||||
|
|
||||||
|
expect(deleted).toBe(0);
|
||||||
|
expect(await rowExists(parent.id)).toBe(true);
|
||||||
|
expect(await rowExists(reply.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(c) reply COMMITS mid-operation (FOR UPDATE path) → returns 0, parent + reply survive; a blind anti-join would lose the reply', async () => {
|
||||||
|
const parent = await seedTopLevel();
|
||||||
|
|
||||||
|
// Second connection holds an open transaction that inserts a reply (taking
|
||||||
|
// FOR KEY SHARE on the parent via the FK) and does NOT commit until we open
|
||||||
|
// the gate — reproducing the "reply not yet committed" window.
|
||||||
|
const conn2 = buildTestDb();
|
||||||
|
let openGate!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
openGate = resolve;
|
||||||
|
});
|
||||||
|
let replyId: string | undefined;
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const replyTx = conn2.transaction().execute(async (trx) => {
|
||||||
|
const row = await trx
|
||||||
|
.insertInto('comments')
|
||||||
|
.values({
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
creatorId: userId,
|
||||||
|
parentCommentId: parent.id,
|
||||||
|
})
|
||||||
|
.returning(['id'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
replyId = row.id as string;
|
||||||
|
// Hold the FOR KEY SHARE lock on the parent until the gate opens.
|
||||||
|
await gate;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let the reply INSERT acquire its lock before the delete starts.
|
||||||
|
await sleep(250);
|
||||||
|
|
||||||
|
// deleteCommentIfChildless does SELECT ... FOR UPDATE on the parent, which
|
||||||
|
// conflicts with the reply's FOR KEY SHARE, so it BLOCKS here.
|
||||||
|
const deletePromise = repo.deleteCommentIfChildless(parent.id);
|
||||||
|
|
||||||
|
// Give the delete time to reach (and block on) its FOR UPDATE, then let the
|
||||||
|
// reply commit. The delete then wakes, re-checks under the lock, sees the
|
||||||
|
// now-committed reply, and returns 0.
|
||||||
|
await sleep(250);
|
||||||
|
openGate();
|
||||||
|
await replyTx;
|
||||||
|
|
||||||
|
const deleted = await deletePromise;
|
||||||
|
|
||||||
|
expect(deleted).toBe(0);
|
||||||
|
expect(await rowExists(parent.id)).toBe(true);
|
||||||
|
expect(replyId).toBeDefined();
|
||||||
|
expect(await rowExists(replyId!)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
// Always release the gate (in case an assertion threw before openGate) and
|
||||||
|
// close the extra connection so global-teardown can DROP the database.
|
||||||
|
openGate();
|
||||||
|
await conn2.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,6 +132,62 @@ export async function createUser(
|
|||||||
return { id: row.id as string };
|
return { id: row.id as string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The default group every workspace has; `groupUserRepo.addUserToDefaultGroup`
|
||||||
|
// (invoked by acceptInvitation) looks it up by `isDefault = true`, so a
|
||||||
|
// workspace under test must have exactly one for the accept path to complete.
|
||||||
|
export async function createDefaultGroup(
|
||||||
|
db: Kysely<any>,
|
||||||
|
workspaceId: string,
|
||||||
|
overrides: { name?: string } = {},
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const suffix = shortId(id);
|
||||||
|
const row = await db
|
||||||
|
.insertInto('groups')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
// name is unique per workspace + NOT NULL.
|
||||||
|
name: overrides.name ?? `group-${suffix}`,
|
||||||
|
isDefault: true,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
.returning(['id'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return { id: row.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pending workspace invitation. `role`/`token` are NOT NULL; `groupIds` is a
|
||||||
|
// nullable uuid[] and `invitedById` a nullable FK to users. Returns the fields a
|
||||||
|
// spec needs to drive acceptInvitation (id + token + the invited email).
|
||||||
|
export async function createInvitation(
|
||||||
|
db: Kysely<any>,
|
||||||
|
args: {
|
||||||
|
workspaceId: string;
|
||||||
|
email: string;
|
||||||
|
invitedById?: string | null;
|
||||||
|
role?: string;
|
||||||
|
token?: string;
|
||||||
|
groupIds?: string[] | null;
|
||||||
|
},
|
||||||
|
): Promise<{ id: string; token: string; email: string }> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const token = args.token ?? `tok-${shortId(id)}`;
|
||||||
|
const row = await db
|
||||||
|
.insertInto('workspaceInvitations')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
email: args.email,
|
||||||
|
role: args.role ?? 'member',
|
||||||
|
token,
|
||||||
|
groupIds: (args.groupIds ?? null) as any,
|
||||||
|
invitedById: args.invitedById ?? null,
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
})
|
||||||
|
.returning(['id'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return { id: row.id as string, token, email: args.email };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSpace(
|
export async function createSpace(
|
||||||
db: Kysely<any>,
|
db: Kysely<any>,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -174,6 +230,40 @@ export async function createPage(
|
|||||||
return { id: row.id as string };
|
return { id: row.id as string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createComment(
|
||||||
|
db: Kysely<any>,
|
||||||
|
args: {
|
||||||
|
workspaceId: string;
|
||||||
|
spaceId: string;
|
||||||
|
pageId: string;
|
||||||
|
creatorId?: string | null;
|
||||||
|
parentCommentId?: string | null;
|
||||||
|
content?: unknown;
|
||||||
|
selection?: string | null;
|
||||||
|
suggestedText?: string | null;
|
||||||
|
type?: string | null;
|
||||||
|
},
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const row = await db
|
||||||
|
.insertInto('comments')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
spaceId: args.spaceId,
|
||||||
|
pageId: args.pageId,
|
||||||
|
creatorId: args.creatorId ?? null,
|
||||||
|
parentCommentId: args.parentCommentId ?? null,
|
||||||
|
content: (args.content ?? null) as any,
|
||||||
|
selection: args.selection ?? null,
|
||||||
|
suggestedText: args.suggestedText ?? null,
|
||||||
|
type: args.type ?? 'page',
|
||||||
|
})
|
||||||
|
.returning(['id'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return { id: row.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createRole(
|
export async function createRole(
|
||||||
db: Kysely<any>,
|
db: Kysely<any>,
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
|
import { WorkspaceInvitationService } from 'src/core/workspace/services/workspace-invitation.service';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createUser,
|
||||||
|
createDefaultGroup,
|
||||||
|
createInvitation,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* acceptInvitation atomicity (issue #324, tail of #244).
|
||||||
|
*
|
||||||
|
* acceptInvitation() reads the invitation OUTSIDE the transaction, then inside a
|
||||||
|
* single tx: inserts the invited user, adds them to the default group, and
|
||||||
|
* deletes the invitation. Two accepts of the SAME invitation therefore race to
|
||||||
|
* insert a user with the same (email, workspaceId) — which the
|
||||||
|
* `users_email_workspace_id_unique` constraint forbids. The service catches that
|
||||||
|
* violation and reports "Invitation already accepted".
|
||||||
|
*
|
||||||
|
* These specs pin the INVARIANT that path protects: no matter how many times the
|
||||||
|
* invitation is accepted (concurrently or repeatedly), the workspace ends up
|
||||||
|
* with exactly ONE membership for the invited email and the invitation is
|
||||||
|
* consumed exactly once — never a duplicate user and never a half-applied state.
|
||||||
|
*
|
||||||
|
* The service is wired with the REAL repos (UserRepo / GroupRepo / GroupUserRepo)
|
||||||
|
* against the test Kysely; only the peripheral collaborators that acceptInvitation
|
||||||
|
* touches AFTER the transaction (mail, session token, billing, audit, env) are
|
||||||
|
* stubbed, so the exercised DB write path is the production one.
|
||||||
|
*/
|
||||||
|
describe('WorkspaceInvitationService.acceptInvitation atomicity [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let service: WorkspaceInvitationService;
|
||||||
|
|
||||||
|
// Count the memberships (user rows) for an email within a workspace — the
|
||||||
|
// quantity the atomicity guarantee is about.
|
||||||
|
async function membershipCount(
|
||||||
|
workspaceId: string,
|
||||||
|
email: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select('id')
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.where('email', '=', email.toLowerCase())
|
||||||
|
.execute();
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invitationExists(invitationId: string): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('workspaceInvitations')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', invitationId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
db = getTestDb();
|
||||||
|
|
||||||
|
const userRepo = new UserRepo(db as any);
|
||||||
|
const groupRepo = new GroupRepo(db as any);
|
||||||
|
const groupUserRepo = new GroupUserRepo(db as any, groupRepo, userRepo);
|
||||||
|
|
||||||
|
// Collaborators used only on the post-commit success tail; safe to stub.
|
||||||
|
const mailService = { sendToQueue: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const domainService = {} as any;
|
||||||
|
const tokenService = {} as any;
|
||||||
|
const sessionService = {
|
||||||
|
createSessionAndToken: jest.fn().mockResolvedValue('test-auth-token'),
|
||||||
|
};
|
||||||
|
const billingQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const environmentService = { isCloud: () => false };
|
||||||
|
const auditService = { log: jest.fn() };
|
||||||
|
|
||||||
|
service = new WorkspaceInvitationService(
|
||||||
|
userRepo,
|
||||||
|
groupUserRepo,
|
||||||
|
mailService as any,
|
||||||
|
domainService,
|
||||||
|
tokenService,
|
||||||
|
sessionService as any,
|
||||||
|
db as any,
|
||||||
|
billingQueue as any,
|
||||||
|
environmentService as any,
|
||||||
|
auditService as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A workspace with its default group, an inviter, and a pending invitation.
|
||||||
|
async function seedInvite(): Promise<{
|
||||||
|
workspace: Workspace;
|
||||||
|
invitationId: string;
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
}> {
|
||||||
|
const { id: workspaceId } = await createWorkspace(db);
|
||||||
|
await createDefaultGroup(db, workspaceId);
|
||||||
|
const inviter = await createUser(db, workspaceId);
|
||||||
|
// Distinct address per invite so specs never collide across the suite.
|
||||||
|
const email = `invitee-${workspaceId.slice(0, 8)}@example.test`;
|
||||||
|
const invite = await createInvitation(db, {
|
||||||
|
workspaceId,
|
||||||
|
email,
|
||||||
|
invitedById: inviter.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// acceptInvitation only reads id/hostname/enforceSso/emailDomains/enforceMfa
|
||||||
|
// off the workspace; a minimal plain object is sufficient.
|
||||||
|
const workspace = {
|
||||||
|
id: workspaceId,
|
||||||
|
hostname: `host-${workspaceId.slice(0, 8)}`,
|
||||||
|
enforceSso: false,
|
||||||
|
enforceMfa: false,
|
||||||
|
emailDomains: [] as string[],
|
||||||
|
} as unknown as Workspace;
|
||||||
|
|
||||||
|
return { workspace, invitationId: invite.id, token: invite.token, email };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('concurrent accepts create a single membership and consume the invitation once', async () => {
|
||||||
|
const { workspace, invitationId, token, email } = await seedInvite();
|
||||||
|
|
||||||
|
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||||
|
|
||||||
|
// Fire two accepts of the SAME invitation at once. They race to insert the
|
||||||
|
// same (email, workspaceId); the unique constraint lets exactly one win.
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||||
|
const rejected = results.filter(
|
||||||
|
(r): r is PromiseRejectedResult => r.status === 'rejected',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exactly one accept succeeds; the other is rejected.
|
||||||
|
expect(fulfilled).toHaveLength(1);
|
||||||
|
expect(rejected).toHaveLength(1);
|
||||||
|
|
||||||
|
// The loser fails via the caught unique-constraint path with the specific
|
||||||
|
// "already accepted" message — not a half-state / generic failure.
|
||||||
|
expect(rejected[0].reason).toBeInstanceOf(BadRequestException);
|
||||||
|
expect(rejected[0].reason.message).toBe('Invitation already accepted');
|
||||||
|
|
||||||
|
// Invariant: exactly one membership, and the invitation is gone.
|
||||||
|
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||||
|
expect(await invitationExists(invitationId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a repeated (sequential) accept does not create a duplicate membership', async () => {
|
||||||
|
const { workspace, invitationId, token, email } = await seedInvite();
|
||||||
|
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||||
|
|
||||||
|
// First accept succeeds and returns an auth token.
|
||||||
|
const first = await service.acceptInvitation({ ...dto }, workspace);
|
||||||
|
expect(first?.authToken).toBe('test-auth-token');
|
||||||
|
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||||
|
expect(await invitationExists(invitationId)).toBe(false);
|
||||||
|
|
||||||
|
// Re-accepting the (now consumed) invitation must be rejected and must NOT
|
||||||
|
// add a second membership. The invitation row is gone, so this hits the
|
||||||
|
// "Invitation not found" guard rather than the unique-constraint path.
|
||||||
|
await expect(
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the single created membership is added to the default group (no partial state)', async () => {
|
||||||
|
const { workspace, invitationId, token, email } = await seedInvite();
|
||||||
|
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||||
|
|
||||||
|
await Promise.allSettled([
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Resolve the one surviving user and assert the whole tx applied: they exist
|
||||||
|
// AND are in the workspace default group (the mid-transaction step), proving
|
||||||
|
// the winning accept committed as a whole rather than leaving a torn state.
|
||||||
|
const user = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id'])
|
||||||
|
.where('workspaceId', '=', workspace.id)
|
||||||
|
.where('email', '=', email.toLowerCase())
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
const defaultGroup = await db
|
||||||
|
.selectFrom('groups')
|
||||||
|
.select(['id'])
|
||||||
|
.where('workspaceId', '=', workspace.id)
|
||||||
|
.where('isDefault', '=', true)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
const membership = await db
|
||||||
|
.selectFrom('groupUsers')
|
||||||
|
.select(['userId'])
|
||||||
|
.where('groupId', '=', defaultGroup.id)
|
||||||
|
.where('userId', '=', user.id)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
expect(membership).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,5 +13,9 @@
|
|||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"marked": "17.0.5"
|
"marked": "17.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
|
"vitest": "4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,21 @@ export default defineConfig({
|
|||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ["src/**/*.{test,spec}.ts"],
|
include: ["src/**/*.{test,spec}.ts"],
|
||||||
|
// Coverage gate (issue #324). v8 provider avoids the istanbul AST-rewrite
|
||||||
|
// that broke on this package's ESM barrel. Thresholds sit a few points
|
||||||
|
// below the level measured on develop, over the files the suite exercises
|
||||||
|
// (`all: false`), so the gate passes today and catches a real regression.
|
||||||
|
coverage: {
|
||||||
|
enabled: true,
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text-summary", "text"],
|
||||||
|
all: false,
|
||||||
|
thresholds: {
|
||||||
|
statements: 54,
|
||||||
|
branches: 44,
|
||||||
|
functions: 60,
|
||||||
|
lines: 54,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^21.1.7",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
"fast-check": "^4.8.0",
|
"fast-check": "^4.8.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vitest": "4.1.6"
|
"vitest": "4.1.6"
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
|
// Coverage gate (issue #324). The v8 provider is used deliberately: the
|
||||||
|
// istanbul provider instruments sources by rewriting their AST, which broke
|
||||||
|
// on the ESM `@docmost/editor-ext` barrel import; v8 collects native
|
||||||
|
// coverage from the runtime and never re-parses ESM, so it sidesteps that.
|
||||||
|
// Thresholds are calibrated a few points BELOW the level measured on
|
||||||
|
// develop so the gate passes today but fails on a real regression. Numbers
|
||||||
|
// reflect the files actually exercised by the suite (`all: false`).
|
||||||
|
coverage: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text-summary', 'text'],
|
||||||
|
all: false,
|
||||||
|
thresholds: {
|
||||||
|
statements: 88,
|
||||||
|
branches: 75,
|
||||||
|
functions: 72,
|
||||||
|
lines: 88,
|
||||||
|
},
|
||||||
|
},
|
||||||
// Runtime suites. The `.test.ts` glob deliberately EXCLUDES the type-only
|
// Runtime suites. The `.test.ts` glob deliberately EXCLUDES the type-only
|
||||||
// contract file (`*.test-d.ts`), which is enforced by the typecheck pass
|
// contract file (`*.test-d.ts`), which is enforced by the typecheck pass
|
||||||
// below instead — so the 35 runtime suites are never typechecked.
|
// below instead — so the 35 runtime suites are never typechecked.
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.0",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
|
"re2": "^1.21.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"y-prosemirror": "1.3.7",
|
"y-prosemirror": "1.3.7",
|
||||||
"yjs": "^13.6.29",
|
"yjs": "^13.6.29",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
deleteTableRow,
|
deleteTableRow,
|
||||||
updateTableCell,
|
updateTableCell,
|
||||||
} from "./lib/node-ops.js";
|
} from "./lib/node-ops.js";
|
||||||
|
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
||||||
import { withPageLock } from "./lib/page-lock.js";
|
import { withPageLock } from "./lib/page-lock.js";
|
||||||
import {
|
import {
|
||||||
applyTextEdits,
|
applyTextEdits,
|
||||||
@@ -1099,6 +1100,29 @@ export class DocmostClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find every occurrence of `query` on a page IN MEMORY, over the plain text of
|
||||||
|
* each text container (reusing the same `getPageRaw` fetch as the other read
|
||||||
|
* tools) — no server search endpoint, no whole-document round-trip through the
|
||||||
|
* model. Returns `{ total, truncated, matches }`; each match carries a ref for
|
||||||
|
* get_node/patch_node (the `#<index>` form resolves with get_node but NOT
|
||||||
|
* patch_node — see SearchMatch.nodeId), plus the top-level block index and a
|
||||||
|
* short context window used to build a unique text `selection` for
|
||||||
|
* create_comment (create_comment has no nodeId param). The pure engine
|
||||||
|
* (`searchInDoc`) owns the traversal, glue, the RE2 ReDoS-safe regex engine
|
||||||
|
* and the empty-query / invalid-or-unsupported-regex errors.
|
||||||
|
*/
|
||||||
|
async searchInPage(pageId: string, query: string, opts: SearchOptions = {}) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const data = await this.getPageRaw(pageId);
|
||||||
|
const result = searchInDoc(
|
||||||
|
data.content ?? { type: "doc", content: [] },
|
||||||
|
query,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
return { pageId, query, ...result };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a table as a matrix. `tableRef` is `#<index>` (from get_outline) or a
|
* Read a table as a matrix. `tableRef` is `#<index>` (from get_outline) or a
|
||||||
* block id of any node inside the table. Returns the cell texts plus a
|
* block id of any node inside the table. Returns the cell texts plus a
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const VERSION = packageJson.version;
|
|||||||
// Exported for that test.
|
// Exported for that test.
|
||||||
export const SERVER_INSTRUCTIONS =
|
export const SERVER_INSTRUCTIONS =
|
||||||
"Docmost editing guide — choose the tool by intent.\n" +
|
"Docmost editing guide — choose the tool by intent.\n" +
|
||||||
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
|
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
|
||||||
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||||
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
||||||
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
|
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
|
||||||
@@ -187,6 +187,19 @@ registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
|
|||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tool: search_in_page
|
||||||
|
registerShared(
|
||||||
|
SHARED_TOOL_SPECS.searchInPage,
|
||||||
|
async ({ pageId, query, regex, caseSensitive, limit }) => {
|
||||||
|
const result = await docmostClient.searchInPage(pageId, query, {
|
||||||
|
regex,
|
||||||
|
caseSensitive,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
return jsonContent(result);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Tool: table_get
|
// Tool: table_get
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"table_get",
|
"table_get",
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Pure, network-free in-page search over a ProseMirror/TipTap document tree.
|
||||||
|
*
|
||||||
|
* `searchInDoc(doc, query, opts)` finds every occurrence of a literal substring
|
||||||
|
* (default) or a regular expression across the page's TEXT CONTAINERS and
|
||||||
|
* reports WHERE each match is — the container's ref (for get_node/patch_node;
|
||||||
|
* see the SearchMatch.nodeId note for the `#<index>` caveat), the top-level
|
||||||
|
* block index, and a short context window around the hit. It never touches the
|
||||||
|
* network, the DB, or the schema mirror; like `comment-anchor.ts` it is
|
||||||
|
* isolated-testable.
|
||||||
|
*
|
||||||
|
* REGEX ENGINE: with `regex:true` the pattern is compiled with RE2 (Google's
|
||||||
|
* linear-time engine), NOT the JS `RegExp`. RE2 has no backtracking, so a
|
||||||
|
* catastrophic pattern (e.g. `(a+)+$`) can never wedge the shared event loop —
|
||||||
|
* it runs in linear time. The trade-off is that RE2 does not support the
|
||||||
|
* backtracking-only features lookaround (`(?=…)`, `(?<=…)`) and backreferences
|
||||||
|
* (`\1`); such a pattern is rejected up front with a clear tool error (see
|
||||||
|
* searchInDoc) rather than being run, which is the desired behaviour — a clear
|
||||||
|
* error the agent can fix beats a server hang.
|
||||||
|
*
|
||||||
|
* WHY plain text (not markdown): each container's inline text is glued into ONE
|
||||||
|
* string via `blockPlainText`, so a match survives inline-mark boundaries
|
||||||
|
* (bold/italic/link splits that fracture a run like "т.е." into several text
|
||||||
|
* nodes) and comment-anchor spans never clutter the haystack.
|
||||||
|
*
|
||||||
|
* The SEARCH UNIT is a text container: a node whose direct children include
|
||||||
|
* text nodes (a paragraph/heading, or the paragraph inside a table cell / list
|
||||||
|
* item). ProseMirror keeps block vs. inline content exclusive, so a container
|
||||||
|
* never nests another container — the walk reaches each cell/item's own text and
|
||||||
|
* the context window is naturally scoped to that specific cell/item, not the
|
||||||
|
* whole top-level block's glued text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import RE2 from "re2";
|
||||||
|
|
||||||
|
import { blockPlainText } from "./node-ops.js";
|
||||||
|
|
||||||
|
/** An RE2 regex instance (RE2 extends `RegExp`, so it is usable as one). */
|
||||||
|
type Re2Regex = InstanceType<typeof RE2>;
|
||||||
|
|
||||||
|
/** True if `value` is a non-null plain object (and not an array). */
|
||||||
|
function isObject(value: any): value is Record<string, any> {
|
||||||
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text container is a node with a `content` array holding at least one text
|
||||||
|
* node (a child with a string `text`). These are the paragraphs/headings whose
|
||||||
|
* glued inline text we search.
|
||||||
|
*/
|
||||||
|
function isTextContainer(node: any): boolean {
|
||||||
|
return (
|
||||||
|
isObject(node) &&
|
||||||
|
Array.isArray(node.content) &&
|
||||||
|
node.content.some((c: any) => isObject(c) && typeof c.text === "string")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options controlling the search engine and result size. */
|
||||||
|
export interface SearchOptions {
|
||||||
|
/** Treat `query` as a RegExp instead of a literal substring (default false). */
|
||||||
|
regex?: boolean;
|
||||||
|
/** Case-sensitive matching (default false). */
|
||||||
|
caseSensitive?: boolean;
|
||||||
|
/** Max matches to RETURN (default 50, clamped to [1, 200]); total is unbounded. */
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One located occurrence. */
|
||||||
|
export interface SearchMatch {
|
||||||
|
/**
|
||||||
|
* The container's ref, for addressing the block with get_node/patch_node: its
|
||||||
|
* `attrs.id` when it has one, otherwise `#<topLevelIndex>` of the nearest
|
||||||
|
* top-level block. Table-cell/list-item paragraphs that carry no id fall back
|
||||||
|
* to the `#<index>` form.
|
||||||
|
*
|
||||||
|
* CAVEAT: the `#<index>` form is accepted by get_node (getNodeByRef resolves
|
||||||
|
* it by top-level index) but NOT by patch_node (replaceNodeById resolves only
|
||||||
|
* by `attrs.id`), so id-less table/cell content can be READ by this ref but
|
||||||
|
* not PATCHED by it.
|
||||||
|
*
|
||||||
|
* To anchor a comment, do NOT pass this ref to create_comment — it has no
|
||||||
|
* nodeId parameter. A top-level comment needs an exact-text `selection` that
|
||||||
|
* occurs once on the page (it fails if the text isn't found), so build a
|
||||||
|
* UNIQUE `selection` from before+match+after and pass THAT as create_comment's
|
||||||
|
* `selection`.
|
||||||
|
*/
|
||||||
|
nodeId: string;
|
||||||
|
/** The top-level block index (as in get_outline). */
|
||||||
|
blockIndex: number;
|
||||||
|
/** The container node's type (paragraph/heading/...). */
|
||||||
|
type: string | undefined;
|
||||||
|
/** ~40 chars of context immediately before the match (from THIS container). */
|
||||||
|
before: string;
|
||||||
|
/** The matched text. */
|
||||||
|
match: string;
|
||||||
|
/** ~40 chars of context immediately after the match (from THIS container). */
|
||||||
|
after: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The search result. `truncated` is true when `total > matches.length`. */
|
||||||
|
export interface SearchResult {
|
||||||
|
total: number;
|
||||||
|
truncated: boolean;
|
||||||
|
matches: SearchMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result-size defaults/ceiling.
|
||||||
|
const DEFAULT_LIMIT = 50;
|
||||||
|
const MAX_LIMIT = 200;
|
||||||
|
|
||||||
|
// Context window on each side of a match.
|
||||||
|
const CONTEXT = 40;
|
||||||
|
|
||||||
|
// Cheap sanity cap on the query/pattern length. ReDoS is handled structurally
|
||||||
|
// by the RE2 engine (linear-time, no backtracking — see the module doc), so we
|
||||||
|
// no longer truncate the per-container text: RE2 scans it in linear time and a
|
||||||
|
// cap could silently drop real matches past it. This just rejects an absurdly
|
||||||
|
// long pattern early with a clear error.
|
||||||
|
const MAX_PATTERN_LENGTH = 1000;
|
||||||
|
|
||||||
|
/** Clamp the requested limit into [1, MAX_LIMIT], defaulting when absent. */
|
||||||
|
function resolveLimit(limit: number | undefined): number {
|
||||||
|
const n = typeof limit === "number" && Number.isFinite(limit) ? limit : DEFAULT_LIMIT;
|
||||||
|
return Math.min(MAX_LIMIT, Math.max(1, Math.floor(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yield the [start, length] of every occurrence of the engine in `text`, in
|
||||||
|
* order. A literal engine uses indexOf (case-folded when requested); a regex
|
||||||
|
* engine uses a global RE2 regex (RE2 extends `RegExp`, so `.exec` advances
|
||||||
|
* `lastIndex` exactly like the native engine). Zero-length regex matches (e.g.
|
||||||
|
* `\b`, `a*`) are SKIPPED and lastIndex is advanced, so a pattern that can match
|
||||||
|
* the empty string cannot flood the results or spin forever.
|
||||||
|
*/
|
||||||
|
function* eachMatch(
|
||||||
|
text: string,
|
||||||
|
query: string,
|
||||||
|
re: Re2Regex | null,
|
||||||
|
caseSensitive: boolean,
|
||||||
|
): Generator<[number, number]> {
|
||||||
|
if (re) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(text)) != null) {
|
||||||
|
const len = m[0].length;
|
||||||
|
if (len === 0) {
|
||||||
|
// Empty match: advance past this position and do not record it.
|
||||||
|
re.lastIndex = m.index + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield [m.index, len];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Literal engine. For case-insensitive search, fold BOTH sides only to locate
|
||||||
|
// the indices; the reported match/context are always sliced from the original
|
||||||
|
// text so the caller gets the real casing (needed to build a unique selection).
|
||||||
|
const haystack = caseSensitive ? text : text.toLowerCase();
|
||||||
|
const needle = caseSensitive ? query : query.toLowerCase();
|
||||||
|
const len = needle.length;
|
||||||
|
let from = 0;
|
||||||
|
for (;;) {
|
||||||
|
const idx = haystack.indexOf(needle, from);
|
||||||
|
if (idx === -1) return;
|
||||||
|
yield [idx, len];
|
||||||
|
from = idx + len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search a ProseMirror document for `query` and return `{ total, truncated,
|
||||||
|
* matches }`. `total` counts EVERY occurrence (even beyond the limit) and
|
||||||
|
* `truncated` flags when the returned list was capped — nothing is silently
|
||||||
|
* dropped.
|
||||||
|
*
|
||||||
|
* Throws a clear, model-actionable error (never a generic failure) on: an
|
||||||
|
* empty/whitespace-only query, an over-long pattern, or — with `regex:true` — a
|
||||||
|
* pattern RE2 rejects (invalid syntax, or the unsupported lookaround/
|
||||||
|
* backreference features), so the agent can fix its input.
|
||||||
|
*/
|
||||||
|
export function searchInDoc(
|
||||||
|
doc: any,
|
||||||
|
query: string,
|
||||||
|
opts: SearchOptions = {},
|
||||||
|
): SearchResult {
|
||||||
|
// --- edge-case guards (fail loudly so the agent can correct the call) ---
|
||||||
|
if (typeof query !== "string" || query.trim().length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"search_in_page: query is empty — pass the text (or regex) to look for.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (query.length > MAX_PATTERN_LENGTH) {
|
||||||
|
throw new Error(
|
||||||
|
`search_in_page: query is too long (${query.length} chars; max ${MAX_PATTERN_LENGTH}). Shorten the search text/pattern.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseSensitive = opts.caseSensitive === true;
|
||||||
|
const limit = resolveLimit(opts.limit);
|
||||||
|
|
||||||
|
// Compile the pattern up front with RE2 (linear-time, ReDoS-safe) so a bad
|
||||||
|
// pattern is a clean tool error rather than a failure deep in the traversal —
|
||||||
|
// and so a catastrophic-backtracking pattern can never wedge the event loop.
|
||||||
|
// RE2 throws both on syntactically invalid input AND on backtracking-only
|
||||||
|
// features it does not implement (lookaround, backreferences); both map to the
|
||||||
|
// same actionable error so the agent rewrites the pattern.
|
||||||
|
let re: Re2Regex | null = null;
|
||||||
|
if (opts.regex === true) {
|
||||||
|
try {
|
||||||
|
re = new RE2(query, caseSensitive ? "g" : "gi");
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`search_in_page: invalid or unsupported regular expression: ${
|
||||||
|
e instanceof Error ? e.message : String(e)
|
||||||
|
} — RE2 does not support lookaround ((?=…)/(?<=…)) or backreferences (\\1); rewrite the pattern without them.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches: SearchMatch[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
const topLevel =
|
||||||
|
isObject(doc) && Array.isArray(doc.content) ? doc.content : [];
|
||||||
|
|
||||||
|
// Descend a top-level block, collecting matches from every text container
|
||||||
|
// within it. blockIndex/topRef stay pinned to the enclosing top-level block.
|
||||||
|
const descend = (node: any, blockIndex: number, topRef: string): void => {
|
||||||
|
if (!isObject(node)) return;
|
||||||
|
|
||||||
|
if (isTextContainer(node)) {
|
||||||
|
// Glue this container's inline text into one string (mark-safe). No length
|
||||||
|
// cap: RE2 scans it in linear time (no ReDoS) and the whole document is
|
||||||
|
// already in memory, so truncating would only risk dropping real matches
|
||||||
|
// in a very long container.
|
||||||
|
const text = blockPlainText(node);
|
||||||
|
|
||||||
|
// The container's own id addresses it verbatim in get_node/patch_node; a
|
||||||
|
// container with no id (e.g. a table-cell paragraph) falls back to the
|
||||||
|
// top-level block's #<index> (readable via get_node, but not patchable —
|
||||||
|
// see the SearchMatch.nodeId note).
|
||||||
|
const id =
|
||||||
|
isObject(node.attrs) && typeof node.attrs.id === "string" && node.attrs.id.length > 0
|
||||||
|
? node.attrs.id
|
||||||
|
: topRef;
|
||||||
|
|
||||||
|
for (const [idx, len] of eachMatch(text, query, re, caseSensitive)) {
|
||||||
|
total++;
|
||||||
|
if (matches.length < limit) {
|
||||||
|
matches.push({
|
||||||
|
nodeId: id,
|
||||||
|
blockIndex,
|
||||||
|
type: node.type,
|
||||||
|
before: text.slice(Math.max(0, idx - CONTEXT), idx),
|
||||||
|
match: text.slice(idx, idx + len),
|
||||||
|
after: text.slice(idx + len, idx + len + CONTEXT),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A text container holds inline content only — no nested containers to
|
||||||
|
// recurse into.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const child of node.content) descend(child, blockIndex, topRef);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < topLevel.length; i++) {
|
||||||
|
descend(topLevel[i], i, `#${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, truncated: total > matches.length, matches };
|
||||||
|
}
|
||||||
@@ -31,6 +31,22 @@ export interface SharedToolSpec {
|
|||||||
inAppKey: string;
|
inAppKey: string;
|
||||||
/** Single canonical model-facing description used by both layers. */
|
/** Single canonical model-facing description used by both layers. */
|
||||||
description: string;
|
description: string;
|
||||||
|
/**
|
||||||
|
* Deferred-tool tier for the IN-APP agent (#332). 'core' tools are always
|
||||||
|
* active; 'deferred' tools are hidden behind the <tool_catalog> and loaded on
|
||||||
|
* demand via the loadTools meta-tool. This is an IN-APP concern only: the
|
||||||
|
* standalone /mcp server ignores this field and registers every tool normally
|
||||||
|
* (registerShared in index.ts reads mcpName/description/buildShape only).
|
||||||
|
*/
|
||||||
|
tier: 'core' | 'deferred';
|
||||||
|
/**
|
||||||
|
* Hand-written one-liner "name — purpose" shown in the in-app agent's
|
||||||
|
* <tool_catalog> for a DEFERRED tool (#332). Deliberately NOT derived from the
|
||||||
|
* description's first sentence — a concise, accurate purpose line. Present on
|
||||||
|
* every spec (core tools too) for uniformity; only deferred ones are rendered.
|
||||||
|
* Inert for the external /mcp server.
|
||||||
|
*/
|
||||||
|
catalogLine: string;
|
||||||
/**
|
/**
|
||||||
* Builds the tool's input schema as a plain object of zod fields (a
|
* Builds the tool's input schema as a plain object of zod fields (a
|
||||||
* ZodRawShape). Called with the consumer's own zod namespace. Omitted for
|
* ZodRawShape). Called with the consumer's own zod namespace. Omitted for
|
||||||
@@ -47,6 +63,8 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
mcpName: 'get_workspace',
|
mcpName: 'get_workspace',
|
||||||
inAppKey: 'getWorkspace',
|
inAppKey: 'getWorkspace',
|
||||||
description: 'Fetch metadata about the current workspace (name, settings).',
|
description: 'Fetch metadata about the current workspace (name, settings).',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getWorkspace — fetch current workspace metadata (name, settings).',
|
||||||
},
|
},
|
||||||
|
|
||||||
listSpaces: {
|
listSpaces: {
|
||||||
@@ -55,6 +73,8 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
description:
|
description:
|
||||||
'List the spaces the current user can access. Returns the array of ' +
|
'List the spaces the current user can access. Returns the array of ' +
|
||||||
'spaces (id, name, slug, ...).',
|
'spaces (id, name, slug, ...).',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'listSpaces — list the spaces the user can access (id, name, slug).',
|
||||||
},
|
},
|
||||||
|
|
||||||
listShares: {
|
listShares: {
|
||||||
@@ -62,6 +82,8 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
inAppKey: 'listShares',
|
inAppKey: 'listShares',
|
||||||
description:
|
description:
|
||||||
'List all public shares in the workspace with page titles and public URLs.',
|
'List all public shares in the workspace with page titles and public URLs.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'listShares — list all public shares in the workspace with their URLs.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- single-pageId read tools ---
|
// --- single-pageId read tools ---
|
||||||
@@ -74,6 +96,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'includes block ids, callouts, tables, link/image attributes) plus the ' +
|
'includes block ids, callouts, tables, link/image attributes) plus the ' +
|
||||||
'slugId used in URLs. Use the block ids it returns to make precise ' +
|
'slugId used in URLs. Use the block ids it returns to make precise ' +
|
||||||
'structural edits or surgical text edits without resending the page.',
|
'structural edits or surgical text edits without resending the page.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"getPageJson — get a page's raw ProseMirror JSON (lossless, with block ids).",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
@@ -88,6 +113,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'count) WITHOUT the full document body. Use it to locate sections/tables ' +
|
'count) WITHOUT the full document body. Use it to locate sections/tables ' +
|
||||||
'and grab block ids cheaply before fetching, patching or inserting ' +
|
'and grab block ids cheaply before fetching, patching or inserting ' +
|
||||||
'individual blocks.',
|
'individual blocks.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
"getOutline — compact outline of a page's top-level blocks with their ids.",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
@@ -104,12 +132,72 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' +
|
'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' +
|
||||||
'`#<index>` to fetch a top-level block by its outline index — use the ' +
|
'`#<index>` to fetch a top-level block by its outline index — use the ' +
|
||||||
'`#<index>` form for tables/rows/cells, which carry no id.',
|
'`#<index>` form for tables/rows/cells, which carry no id.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
"getNode — fetch one block's ProseMirror subtree by block id or #index.",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
nodeId: z.string().min(1),
|
nodeId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- in-page occurrence search (client-side, over ProseMirror plain text) ---
|
||||||
|
|
||||||
|
searchInPage: {
|
||||||
|
mcpName: 'search_in_page',
|
||||||
|
inAppKey: 'searchInPage',
|
||||||
|
description:
|
||||||
|
'Find every occurrence of a string (or regex) INSIDE one page and get ' +
|
||||||
|
'WHERE each is — instead of pulling blocks one-by-one with get_node. ' +
|
||||||
|
'Searches the plain text of each text block/cell (marks glued, so a match ' +
|
||||||
|
'survives bold/italic/link splits; comment anchors do not interfere). ' +
|
||||||
|
'Returns { total, truncated, matches:[{ nodeId, blockIndex, type, before, ' +
|
||||||
|
'match, after }] }: `nodeId` is the block id (or "#<index>" for ' +
|
||||||
|
'table/cell content) — pass it to get_node/patch_node (the "#<index>" ' +
|
||||||
|
'form resolves with get_node but NOT patch_node, which only accepts a real ' +
|
||||||
|
'block id). To anchor a comment, do NOT pass nodeId to create_comment (it ' +
|
||||||
|
'has no nodeId param); build a UNIQUE text selection from before+match+' +
|
||||||
|
'after and pass it as create_comment\'s `selection`. `blockIndex` is the ' +
|
||||||
|
'get_outline index; `before`/`after` give ~40 chars of context to build ' +
|
||||||
|
'that unique selection. `total` counts all ' +
|
||||||
|
'hits and `truncated` is true when more than `limit` were found (nothing ' +
|
||||||
|
'is silently dropped). Default is a literal, case-INSENSITIVE substring; ' +
|
||||||
|
'set regex:true for an RE2 regular expression (linear-time, ReDoS-safe: ' +
|
||||||
|
'char classes, word boundaries, anchors and quantifiers work; lookaround ' +
|
||||||
|
'(?=…)/(?<=…) and backreferences \\1 are NOT supported) and ' +
|
||||||
|
'caseSensitive:true to match case. Ideal for systematic ' +
|
||||||
|
'editorial sweeps (unquoted "ё", straight quotes, "т.е.", stray units). An ' +
|
||||||
|
'invalid regex or an empty query returns a clear error to fix.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
'searchInPage — find every occurrence of a string/regex inside one page, with locations.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('ID of the page to search'),
|
||||||
|
query: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('The text to find (a literal substring, or a regex when regex:true)'),
|
||||||
|
regex: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Treat query as an RE2 regular expression — linear-time, ReDoS-safe; ' +
|
||||||
|
'no lookaround or backreferences (default false).',
|
||||||
|
),
|
||||||
|
caseSensitive: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Case-sensitive matching (default false).'),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.optional()
|
||||||
|
.describe('Max matches to RETURN (default 50, max 200); total is always reported.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
// --- node delete ---
|
// --- node delete ---
|
||||||
|
|
||||||
deleteNode: {
|
deleteNode: {
|
||||||
@@ -118,6 +206,8 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
description:
|
description:
|
||||||
'Remove a single block by its attrs.id (from the page outline or ' +
|
'Remove a single block by its attrs.id (from the page outline or ' +
|
||||||
'page-JSON view) WITHOUT resending the whole document.',
|
'page-JSON view) WITHOUT resending the whole document.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'deleteNode — remove a single content block by its block id.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
nodeId: z.string().min(1),
|
nodeId: z.string().min(1),
|
||||||
@@ -149,6 +239,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'JSON object or a JSON string (both accepted). Cheaper and safer than ' +
|
'JSON object or a JSON string (both accepted). Cheaper and safer than ' +
|
||||||
'replacing the whole document for one-block structural edits. Reversible: ' +
|
'replacing the whole document for one-block structural edits. Reversible: ' +
|
||||||
'the previous version is kept in page history.',
|
'the previous version is kept in page history.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'patchNode — replace one block with a new ProseMirror node, keeping its id.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1).describe('ID of the page containing the block'),
|
pageId: z.string().min(1).describe('ID of the page containing the block'),
|
||||||
nodeId: z
|
nodeId: z
|
||||||
@@ -191,6 +284,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
||||||
'JSON object or a JSON string (both accepted). Reversible via page history.',
|
'JSON object or a JSON string (both accepted). Reversible via page history.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'insertNode — insert a block before/after an anchor, or append at the end.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
node: z
|
node: z
|
||||||
@@ -224,6 +320,8 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
mcpName: 'unshare_page',
|
mcpName: 'unshare_page',
|
||||||
inAppKey: 'unsharePage',
|
inAppKey: 'unsharePage',
|
||||||
description: 'Remove the public share of a page (revokes the public URL).',
|
description: 'Remove the public share of a page (revokes the public URL).',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: "unsharePage — revoke a page's public share (removes the public URL).",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1).describe('ID of the page to unshare'),
|
pageId: z.string().min(1).describe('ID of the page to unshare'),
|
||||||
}),
|
}),
|
||||||
@@ -241,6 +339,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
"`from`/`to` each accept a historyId, or null/'current' for the page's " +
|
"`from`/`to` each accept a historyId, or null/'current' for the page's " +
|
||||||
'current content (defaults: from=current, to=current — pass a historyId ' +
|
'current content (defaults: from=current, to=current — pass a historyId ' +
|
||||||
'from the page-history list to compare against the live page).',
|
'from the page-history list to compare against the live page).',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'diffPageVersions — diff two page versions and return the change set + summary.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
from: z
|
from: z
|
||||||
@@ -261,6 +362,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
"List a page's saved versions (Docmost auto-snapshots on every save), " +
|
"List a page's saved versions (Docmost auto-snapshots on every save), " +
|
||||||
'newest first, cursor-paginated. Returns { items, nextCursor }; each ' +
|
'newest first, cursor-paginated. Returns { items, nextCursor }; each ' +
|
||||||
"item's id is the historyId to pass to the page diff or restore tools.",
|
"item's id is the historyId to pass to the page diff or restore tools.",
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"listPageHistory — list a page's saved versions (newest first, paginated).",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
cursor: z
|
cursor: z
|
||||||
@@ -278,6 +382,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'as the page\'s current content (Docmost has no restore endpoint, so ' +
|
'as the page\'s current content (Docmost has no restore endpoint, so ' +
|
||||||
'this creates a NEW history snapshot — the restore is itself revertible). ' +
|
'this creates a NEW history snapshot — the restore is itself revertible). ' +
|
||||||
'Get the historyId from the page-history list.',
|
'Get the historyId from the page-history list.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'restorePageVersion — restore a page to a saved history version (revertible).',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
historyId: z.string().min(1),
|
historyId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
@@ -295,6 +402,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'thread records are NOT created/updated/deleted on the server by this ' +
|
'thread records are NOT created/updated/deleted on the server by this ' +
|
||||||
'tool — only the page body + inline comment marks are written; manage ' +
|
'tool — only the page body + inline comment marks are written; manage ' +
|
||||||
'comment threads via the comment tools/UI.',
|
'comment threads via the comment tools/UI.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"importPageMarkdown — replace a page's content from exported Docmost Markdown.",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
markdown: z.string().min(1),
|
markdown: z.string().min(1),
|
||||||
@@ -311,6 +421,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'entirely server-side — the document is NOT sent through the model. The ' +
|
'entirely server-side — the document is NOT sent through the model. The ' +
|
||||||
'target keeps its own title and slug; only its body is replaced. Ideal ' +
|
'target keeps its own title and slug; only its body is replaced. Ideal ' +
|
||||||
"for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.",
|
"for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.",
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"copyPageContent — replace one page's body with a copy of another page's body.",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
sourcePageId: z.string().min(1).describe('Page to copy content FROM'),
|
sourcePageId: z.string().min(1).describe('Page to copy content FROM'),
|
||||||
targetPageId: z
|
targetPageId: z
|
||||||
@@ -348,6 +461,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'page JSON and use a structural node patch/update to set its marks. ' +
|
'page JSON and use a structural node patch/update to set its marks. ' +
|
||||||
'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
|
'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
|
||||||
'world",replace:"Hello there"}] (crosses a bold boundary).',
|
'world",replace:"Hello there"}] (crosses a bold boundary).',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
"editPageText — surgical find/replace of plain text in a page, preserving ids/marks.",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().describe('ID of the page to edit'),
|
pageId: z.string().describe('ID of the page to edit'),
|
||||||
edits: z
|
edits: z
|
||||||
@@ -386,6 +502,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'server instance that created it: in a multi-replica deployment without ' +
|
'server instance that created it: in a multi-replica deployment without ' +
|
||||||
'sticky sessions a blob stored on one instance is not retrievable via the ' +
|
'sticky sessions a blob stored on one instance is not retrievable via the ' +
|
||||||
'sandbox URL on another (it 404s like an expired one).',
|
'sandbox URL on another (it 404s like an expired one).',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'stashPage — serialize a whole page to a short anonymous URL without loading its body.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const HOST_CONTRACT_METHODS = [
|
|||||||
"getOutline",
|
"getOutline",
|
||||||
"getPageJson",
|
"getPageJson",
|
||||||
"getNode",
|
"getNode",
|
||||||
|
"searchInPage",
|
||||||
"getTable",
|
"getTable",
|
||||||
"listComments",
|
"listComments",
|
||||||
"getComment",
|
"getComment",
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { searchInDoc } from "../../build/lib/page-search.js";
|
||||||
|
import { getNodeByRef } from "../../build/lib/node-ops.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Document builders. Mirror the Docmost ProseMirror shape: paragraphs/headings
|
||||||
|
// carry an attrs.id and hold text nodes; a text node may carry marks, and
|
||||||
|
// adjacent runs with different marks are GLUED by blockPlainText so a match can
|
||||||
|
// straddle a mark boundary. Table cells hold id-less paragraphs.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const text = (t, marks) => (marks ? { type: "text", text: t, marks } : { type: "text", text: t });
|
||||||
|
const para = (id, ...children) => ({ type: "paragraph", attrs: { id }, content: children });
|
||||||
|
const heading = (id, level, t) => ({
|
||||||
|
type: "heading",
|
||||||
|
attrs: { id, level },
|
||||||
|
content: [text(t)],
|
||||||
|
});
|
||||||
|
|
||||||
|
function doc(...content) {
|
||||||
|
return { type: "doc", content };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("literal substring: finds every occurrence with total/truncated and refs", () => {
|
||||||
|
const d = doc(
|
||||||
|
para("p1", text("The cat sat on the cat mat.")),
|
||||||
|
heading("h1", 2, "Another cat here"),
|
||||||
|
);
|
||||||
|
const res = searchInDoc(d, "cat");
|
||||||
|
assert.equal(res.total, 3);
|
||||||
|
assert.equal(res.truncated, false);
|
||||||
|
assert.equal(res.matches.length, 3);
|
||||||
|
// First hit: paragraph p1, block index 0.
|
||||||
|
assert.equal(res.matches[0].nodeId, "p1");
|
||||||
|
assert.equal(res.matches[0].blockIndex, 0);
|
||||||
|
assert.equal(res.matches[0].type, "paragraph");
|
||||||
|
assert.equal(res.matches[0].match, "cat");
|
||||||
|
// Third hit is in the heading (block index 1).
|
||||||
|
assert.equal(res.matches[2].nodeId, "h1");
|
||||||
|
assert.equal(res.matches[2].blockIndex, 1);
|
||||||
|
assert.equal(res.matches[2].type, "heading");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("context windows: before/after are drawn from the SAME container", () => {
|
||||||
|
const d = doc(para("p1", text("alpha beta gamma delta")));
|
||||||
|
const res = searchInDoc(d, "gamma");
|
||||||
|
assert.equal(res.matches.length, 1);
|
||||||
|
assert.equal(res.matches[0].before, "alpha beta ");
|
||||||
|
assert.equal(res.matches[0].match, "gamma");
|
||||||
|
assert.equal(res.matches[0].after, " delta");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("context windows are bounded to ~40 chars each side", () => {
|
||||||
|
const long = "x".repeat(100);
|
||||||
|
const d = doc(para("p1", text(long + "NEEDLE" + long)));
|
||||||
|
const res = searchInDoc(d, "NEEDLE");
|
||||||
|
assert.equal(res.matches.length, 1);
|
||||||
|
assert.equal(res.matches[0].before.length, 40);
|
||||||
|
assert.equal(res.matches[0].after.length, 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("case-insensitive by default; caseSensitive:true narrows", () => {
|
||||||
|
const d = doc(para("p1", text("Cat CAT cat")));
|
||||||
|
assert.equal(searchInDoc(d, "cat").total, 3);
|
||||||
|
assert.equal(searchInDoc(d, "cat", { caseSensitive: true }).total, 1);
|
||||||
|
// Reported match preserves the ORIGINAL casing even under a folded search.
|
||||||
|
const res = searchInDoc(d, "cat");
|
||||||
|
assert.deepEqual(
|
||||||
|
res.matches.map((m) => m.match),
|
||||||
|
["Cat", "CAT", "cat"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("match survives an inline mark boundary (glued runs)", () => {
|
||||||
|
// "т.е." is fractured across three text nodes by bold/italic marks.
|
||||||
|
const d = doc(
|
||||||
|
para(
|
||||||
|
"p1",
|
||||||
|
text("вводное слово, "),
|
||||||
|
text("т", [{ type: "bold" }]),
|
||||||
|
text(".", [{ type: "italic" }]),
|
||||||
|
text("е", [{ type: "bold" }]),
|
||||||
|
text(". дальше"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const res = searchInDoc(d, "т.е.");
|
||||||
|
assert.equal(res.total, 1);
|
||||||
|
assert.equal(res.matches[0].match, "т.е.");
|
||||||
|
assert.equal(res.matches[0].nodeId, "p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regex engine: character classes and word boundaries", () => {
|
||||||
|
const d = doc(para("p1", text("v1 v22 version v3")));
|
||||||
|
const res = searchInDoc(d, "\\bv\\d+\\b", { regex: true });
|
||||||
|
assert.deepEqual(
|
||||||
|
res.matches.map((m) => m.match),
|
||||||
|
["v1", "v22", "v3"],
|
||||||
|
);
|
||||||
|
// "version" is not matched by \bv\d+\b.
|
||||||
|
assert.equal(res.total, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regex is case-insensitive by default and respects caseSensitive", () => {
|
||||||
|
const d = doc(para("p1", text("Foo foo FOO")));
|
||||||
|
assert.equal(searchInDoc(d, "foo", { regex: true }).total, 3);
|
||||||
|
assert.equal(
|
||||||
|
searchInDoc(d, "foo", { regex: true, caseSensitive: true }).total,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regex empty/zero-length matches are skipped, not flooded", () => {
|
||||||
|
const d = doc(para("p1", text("abc")));
|
||||||
|
// `a*` can match the empty string at every position; we must not emit those.
|
||||||
|
const res = searchInDoc(d, "a*", { regex: true });
|
||||||
|
assert.equal(res.total, 1);
|
||||||
|
assert.equal(res.matches[0].match, "a");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nodeId for a table cell paragraph WITHOUT an id falls back to #<topLevelIndex>", () => {
|
||||||
|
// A table at top-level block index 1; its cell paragraphs carry no attrs.id.
|
||||||
|
const cellPara = (t) => ({ type: "paragraph", content: [text(t)] });
|
||||||
|
const d = doc(
|
||||||
|
para("intro", text("before the table")),
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableRow",
|
||||||
|
content: [
|
||||||
|
{ type: "tableCell", content: [cellPara("needle in a cell")] },
|
||||||
|
{ type: "tableHeader", content: [cellPara("another needle")] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const res = searchInDoc(d, "needle");
|
||||||
|
assert.equal(res.total, 2);
|
||||||
|
// Both cell hits report the table's top-level #<index> (block 1) since the
|
||||||
|
// cell paragraphs have no id.
|
||||||
|
for (const m of res.matches) {
|
||||||
|
assert.equal(m.nodeId, "#1");
|
||||||
|
assert.equal(m.blockIndex, 1);
|
||||||
|
}
|
||||||
|
// Context is scoped to the specific cell, not the whole table's glued text.
|
||||||
|
assert.equal(res.matches[0].after, " in a cell");
|
||||||
|
assert.equal(res.matches[1].before, "another ");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nodeId uses attrs.id when the container has one (paragraph & heading)", () => {
|
||||||
|
const d = doc(heading("h9", 1, "heading needle"), para("p9", text("para needle")));
|
||||||
|
const res = searchInDoc(d, "needle");
|
||||||
|
assert.equal(res.matches[0].nodeId, "h9");
|
||||||
|
assert.equal(res.matches[1].nodeId, "p9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("limit caps the returned matches but total and truncated stay honest", () => {
|
||||||
|
const d = doc(para("p1", text("x ".repeat(10).trim()))); // 10 'x'
|
||||||
|
const res = searchInDoc(d, "x", { limit: 3 });
|
||||||
|
assert.equal(res.total, 10);
|
||||||
|
assert.equal(res.matches.length, 3);
|
||||||
|
assert.equal(res.truncated, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("limit is clamped to the [1, 200] range", () => {
|
||||||
|
const d = doc(para("p1", text("a".repeat(5))));
|
||||||
|
// A limit above the ceiling still returns all 5 (< 200) without truncation.
|
||||||
|
const hi = searchInDoc(d, "a", { limit: 9999 });
|
||||||
|
assert.equal(hi.matches.length, 5);
|
||||||
|
assert.equal(hi.truncated, false);
|
||||||
|
// A non-positive limit clamps up to 1.
|
||||||
|
const lo = searchInDoc(d, "a", { limit: 0 });
|
||||||
|
assert.equal(lo.matches.length, 1);
|
||||||
|
assert.equal(lo.total, 5);
|
||||||
|
assert.equal(lo.truncated, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid regex throws a clear tool error", () => {
|
||||||
|
const d = doc(para("p1", text("hi")));
|
||||||
|
assert.throws(
|
||||||
|
() => searchInDoc(d, "(", { regex: true }),
|
||||||
|
/invalid or unsupported regular expression/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RE2: a catastrophic-backtracking pattern completes FAST and correctly (no ReDoS)", () => {
|
||||||
|
// (a+)+$ against a long run of 'a' followed by a non-'a' is the classic
|
||||||
|
// catastrophic-backtracking case that wedges the JS RegExp engine for
|
||||||
|
// seconds/forever. Under RE2 (linear time) it returns effectively instantly.
|
||||||
|
const d = doc(para("p1", text("a".repeat(50_000) + "b")));
|
||||||
|
const t0 = Date.now();
|
||||||
|
const res = searchInDoc(d, "(a+)+$", { regex: true });
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
// No '$'-anchored all-'a' run exists (there's a trailing 'b'), so no match.
|
||||||
|
assert.equal(res.total, 0);
|
||||||
|
assert.equal(res.matches.length, 0);
|
||||||
|
// Generous ceiling: the JS engine would take orders of magnitude longer.
|
||||||
|
assert.ok(elapsed < 1000, `expected fast completion, took ${elapsed}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RE2: catastrophic pattern that DOES match still completes fast and finds it", () => {
|
||||||
|
// (a+)+b matches the whole "aaa…b"; RE2 finds it in linear time.
|
||||||
|
const d = doc(para("p1", text("a".repeat(20_000) + "b")));
|
||||||
|
const t0 = Date.now();
|
||||||
|
const res = searchInDoc(d, "(a+)+b", { regex: true });
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
assert.equal(res.total, 1);
|
||||||
|
assert.equal(res.matches[0].match, "a".repeat(20_000) + "b");
|
||||||
|
assert.ok(elapsed < 1000, `expected fast completion, took ${elapsed}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("RE2: unsupported lookaround/backreference patterns yield the clear unsupported-regex error", () => {
|
||||||
|
const d = doc(para("p1", text("hello")));
|
||||||
|
// Lookahead / lookbehind / backreference are backtracking-only features RE2
|
||||||
|
// rejects at compile time — a clean tool error, never a hang.
|
||||||
|
assert.throws(
|
||||||
|
() => searchInDoc(d, "foo(?=bar)", { regex: true }),
|
||||||
|
/invalid or unsupported regular expression/i,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => searchInDoc(d, "(?<=foo)bar", { regex: true }),
|
||||||
|
/invalid or unsupported regular expression/i,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => searchInDoc(d, "(a)\\1", { regex: true }),
|
||||||
|
/invalid or unsupported regular expression/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("F3 round-trip: every match's nodeId resolves through the REAL getNodeByRef consumer", () => {
|
||||||
|
// A doc mixing an attrs.id paragraph and an id-less table-cell paragraph, so
|
||||||
|
// both ref formats (block id and "#<index>") are exercised end-to-end.
|
||||||
|
const cellPara = (t) => ({ type: "paragraph", content: [text(t)] });
|
||||||
|
const d = doc(
|
||||||
|
para("intro", text("find needle here")), // attrs.id ref -> "intro"
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableRow",
|
||||||
|
content: [
|
||||||
|
{ type: "tableCell", content: [cellPara("cell needle")] }, // id-less -> "#1"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const res = searchInDoc(d, "needle");
|
||||||
|
assert.equal(res.total, 2);
|
||||||
|
|
||||||
|
// Match 0: an attrs.id ref must resolve to that exact paragraph.
|
||||||
|
assert.equal(res.matches[0].nodeId, "intro");
|
||||||
|
const byId = getNodeByRef(d, res.matches[0].nodeId);
|
||||||
|
assert.ok(byId, "attrs.id ref must resolve via getNodeByRef");
|
||||||
|
assert.equal(byId.type, "paragraph");
|
||||||
|
assert.equal(byId.node.attrs.id, "intro");
|
||||||
|
|
||||||
|
// Match 1: an id-less table cell falls back to the table's "#<index>", which
|
||||||
|
// getNodeByRef resolves to the TOP-LEVEL block (the table) by index.
|
||||||
|
assert.equal(res.matches[1].nodeId, "#1");
|
||||||
|
const byIndex = getNodeByRef(d, res.matches[1].nodeId);
|
||||||
|
assert.ok(byIndex, "#<index> ref must resolve via getNodeByRef");
|
||||||
|
assert.equal(byIndex.type, "table");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("F4: before/after are pinned correctly at string edges (clamp not dropped)", () => {
|
||||||
|
// Match within the first CONTEXT (40) chars of a container LONGER than
|
||||||
|
// CONTEXT: before is only the chars that exist, never a negative-index slice.
|
||||||
|
const head = doc(para("p1", text("ab NEEDLE" + "x".repeat(100))));
|
||||||
|
const r1 = searchInDoc(head, "NEEDLE");
|
||||||
|
assert.equal(r1.matches.length, 1);
|
||||||
|
assert.equal(r1.matches[0].before, "ab ");
|
||||||
|
assert.equal(r1.matches[0].after.length, 40); // plenty of trailing 'x'
|
||||||
|
|
||||||
|
// Match at index 0: before is empty.
|
||||||
|
const atStart = doc(para("p1", text("NEEDLE tail")));
|
||||||
|
const r2 = searchInDoc(atStart, "NEEDLE");
|
||||||
|
assert.equal(r2.matches[0].before, "");
|
||||||
|
assert.equal(r2.matches[0].after, " tail");
|
||||||
|
|
||||||
|
// Match at the container END: after is empty.
|
||||||
|
const atEnd = doc(para("p1", text("lead NEEDLE")));
|
||||||
|
const r3 = searchInDoc(atEnd, "NEEDLE");
|
||||||
|
assert.equal(r3.matches[0].before, "lead ");
|
||||||
|
assert.equal(r3.matches[0].after, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty or whitespace-only query is rejected", () => {
|
||||||
|
const d = doc(para("p1", text("hi")));
|
||||||
|
assert.throws(() => searchInDoc(d, ""), /query is empty/i);
|
||||||
|
assert.throws(() => searchInDoc(d, " "), /query is empty/i);
|
||||||
|
assert.throws(() => searchInDoc(d, undefined), /query is empty/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an over-long pattern is rejected (anti-ReDoS pattern cap)", () => {
|
||||||
|
const d = doc(para("p1", text("hi")));
|
||||||
|
assert.throws(() => searchInDoc(d, "a".repeat(1001)), /too long/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no matches yields an empty, non-truncated result", () => {
|
||||||
|
const d = doc(para("p1", text("nothing to see")));
|
||||||
|
const res = searchInDoc(d, "zebra");
|
||||||
|
assert.deepEqual(res, { total: 0, truncated: false, matches: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("null-safe on a missing/empty doc", () => {
|
||||||
|
assert.deepEqual(searchInDoc(null, "x"), {
|
||||||
|
total: 0,
|
||||||
|
truncated: false,
|
||||||
|
matches: [],
|
||||||
|
});
|
||||||
|
assert.deepEqual(searchInDoc({ type: "doc" }, "x"), {
|
||||||
|
total: 0,
|
||||||
|
truncated: false,
|
||||||
|
matches: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -635,13 +635,17 @@ const Attachment = Node.create({
|
|||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-name"),
|
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
|
||||||
|
parseHTML: (el: HTMLElement) =>
|
||||||
|
el.getAttribute("data-attachment-name") || null,
|
||||||
renderHTML: (attrs: Record<string, any>) =>
|
renderHTML: (attrs: Record<string, any>) =>
|
||||||
attrs.name ? { "data-attachment-name": attrs.name } : {},
|
attrs.name ? { "data-attachment-name": attrs.name } : {},
|
||||||
},
|
},
|
||||||
mime: {
|
mime: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-mime"),
|
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
|
||||||
|
parseHTML: (el: HTMLElement) =>
|
||||||
|
el.getAttribute("data-attachment-mime") || null,
|
||||||
renderHTML: (attrs: Record<string, any>) =>
|
renderHTML: (attrs: Record<string, any>) =>
|
||||||
attrs.mime ? { "data-attachment-mime": attrs.mime } : {},
|
attrs.mime ? { "data-attachment-mime": attrs.mime } : {},
|
||||||
},
|
},
|
||||||
@@ -689,7 +693,10 @@ const Video = Node.create({
|
|||||||
},
|
},
|
||||||
alt: {
|
alt: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (el: HTMLElement) => el.getAttribute("aria-label"),
|
// Empty-string-vs-absent idempotency: coerce "" back to the default so a
|
||||||
|
// stray empty `aria-label` never materializes `alt: ""` on a video stored
|
||||||
|
// with no alt (same GS-EDIT-REVERT class as the image `alt` fix).
|
||||||
|
parseHTML: (el: HTMLElement) => el.getAttribute("aria-label") || null,
|
||||||
renderHTML: (attrs: Record<string, any>) =>
|
renderHTML: (attrs: Record<string, any>) =>
|
||||||
attrs.alt ? { "aria-label": attrs.alt } : {},
|
attrs.alt ? { "aria-label": attrs.alt } : {},
|
||||||
},
|
},
|
||||||
@@ -864,13 +871,15 @@ const diagramAttributes = () => ({
|
|||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-title"),
|
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
|
||||||
|
parseHTML: (el: HTMLElement) => el.getAttribute("data-title") || null,
|
||||||
renderHTML: (attrs: Record<string, any>) =>
|
renderHTML: (attrs: Record<string, any>) =>
|
||||||
attrs.title ? { "data-title": attrs.title } : {},
|
attrs.title ? { "data-title": attrs.title } : {},
|
||||||
},
|
},
|
||||||
alt: {
|
alt: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-alt"),
|
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
|
||||||
|
parseHTML: (el: HTMLElement) => el.getAttribute("data-alt") || null,
|
||||||
renderHTML: (attrs: Record<string, any>) =>
|
renderHTML: (attrs: Record<string, any>) =>
|
||||||
attrs.alt ? { "data-alt": attrs.alt } : {},
|
attrs.alt ? { "data-alt": attrs.alt } : {},
|
||||||
},
|
},
|
||||||
@@ -1106,7 +1115,8 @@ const Pdf = Node.create({
|
|||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-name"),
|
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
|
||||||
|
parseHTML: (el: HTMLElement) => el.getAttribute("data-name") || null,
|
||||||
renderHTML: (attrs: Record<string, any>) =>
|
renderHTML: (attrs: Record<string, any>) =>
|
||||||
attrs.name ? { "data-name": attrs.name } : {},
|
attrs.name ? { "data-name": attrs.name } : {},
|
||||||
},
|
},
|
||||||
@@ -1491,6 +1501,29 @@ export const docmostExtensions = [
|
|||||||
...parent.height,
|
...parent.height,
|
||||||
parseHTML: (el: HTMLElement) => el.getAttribute("height"),
|
parseHTML: (el: HTMLElement) => el.getAttribute("height"),
|
||||||
},
|
},
|
||||||
|
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class). `marked`
|
||||||
|
// renders `` as `<img alt="">`, so the stock Image `alt`
|
||||||
|
// parseHTML (`getAttribute("alt")`) materializes `alt: ""` on an image
|
||||||
|
// that was stored with NO alt (attr absent). That is a false diff against
|
||||||
|
// the editor-stored form (a no-alt image has alt ABSENT, not ""), so a
|
||||||
|
// git-sync / ai-chat touch of a page with a plain image produced phantom
|
||||||
|
// churn. Coerce an empty string back to the attr's default (null) so the
|
||||||
|
// import is idempotent. A real alt survives verbatim (`|| undefined` keeps
|
||||||
|
// the truthy value; the default fills the empty case). `title` is coerced
|
||||||
|
// the same way for the whole class, even though `marked` does not
|
||||||
|
// currently emit `title=""` — defence in depth against any path that does.
|
||||||
|
// NOTE: this DIVERGES from editor-ext's literal image `alt` parseHTML
|
||||||
|
// (`getAttribute("alt")`, which returns "" verbatim), but CONVERGES on
|
||||||
|
// editor-ext's real STORED shape: an editor image inserted without alt
|
||||||
|
// renders with no `alt` attribute and re-parses as absent, never "".
|
||||||
|
alt: {
|
||||||
|
...parent.alt,
|
||||||
|
parseHTML: (el: HTMLElement) => el.getAttribute("alt") || null,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...parent.title,
|
||||||
|
parseHTML: (el: HTMLElement) => el.getAttribute("title") || null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}).configure({ inline: false }),
|
}).configure({ inline: false }),
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
/**
|
||||||
|
* Reusable round-trip-STABILITY matrix helper (fixtures-first).
|
||||||
|
*
|
||||||
|
* A single stored node authored WITHOUT a given string attribute (attr
|
||||||
|
* absent / undefined) must not gain a phantom EMPTY-STRING value after a
|
||||||
|
* markdown round-trip — the "empty-string-vs-absent" churn class. This helper,
|
||||||
|
* given a node spec, drives a matrix of attribute combinations through the REAL
|
||||||
|
* converter (`convertProseMirrorToMarkdown` -> `markdownToProseMirror`) and
|
||||||
|
* asserts byte-stability on two contours:
|
||||||
|
*
|
||||||
|
* 1. RAW round-trip: for the node under test, every attribute the round-trip
|
||||||
|
* materializes must equal what the INPUT authored — an authored attr keeps
|
||||||
|
* its value, an ABSENT attr may only reappear at its SCHEMA DEFAULT. If an
|
||||||
|
* absent attr comes back as a NON-default value (e.g. `alt: ""` where the
|
||||||
|
* default is `null`), that is an instability and is reported precisely as
|
||||||
|
* `type.attr: absent -> "<got>"`. This is the contour git-sync / stored
|
||||||
|
* JSON diffs on, so masking it only in `canonicalize` would leave the noise.
|
||||||
|
*
|
||||||
|
* 2. CANONICAL round-trip: `canonicalizeContent(original)` must deep-equal
|
||||||
|
* `canonicalizeContent(roundtrip)` (a second, semantic contour).
|
||||||
|
*
|
||||||
|
* The ONLY normalization the helper treats as allowed (not an instability) is
|
||||||
|
* the DOCUMENTED numeric width/height/size/aspectRatio -> string coercion the
|
||||||
|
* converter performs on purpose (a stored numeric `640` re-parses via
|
||||||
|
* `getAttribute` as the string `"640"`). It is encoded here as an explicit
|
||||||
|
* per-spec `numericStringAttrs` set applied to BOTH contours, NOT a silent skip.
|
||||||
|
*
|
||||||
|
* The helper is node-type agnostic: image and the whole media family share the
|
||||||
|
* `align !== "center"` predicate + `<!--name {…}-->` comment machinery, so one
|
||||||
|
* matrix guards the shared class.
|
||||||
|
*/
|
||||||
|
import { getSchema } from "@tiptap/core";
|
||||||
|
import {
|
||||||
|
convertProseMirrorToMarkdown,
|
||||||
|
markdownToProseMirror,
|
||||||
|
canonicalizeContent,
|
||||||
|
docmostExtensions,
|
||||||
|
} from "../src/lib/index.js";
|
||||||
|
import { firstDivergence } from "./roundtrip-helpers.js";
|
||||||
|
|
||||||
|
/** One attribute's two probe values. */
|
||||||
|
export interface AttrMatrixEntry {
|
||||||
|
/** Attribute name on the node. */
|
||||||
|
attr: string;
|
||||||
|
/**
|
||||||
|
* The "default" pick. `undefined` means the attribute is OMITTED entirely
|
||||||
|
* (the absent case — the one that can materialize an empty string on import).
|
||||||
|
* A concrete value is authored verbatim.
|
||||||
|
*/
|
||||||
|
default: unknown;
|
||||||
|
/** A representative NON-default value to exercise (must survive verbatim). */
|
||||||
|
nonDefault: unknown;
|
||||||
|
/**
|
||||||
|
* Marks the attr as a member of the EMPTY-STRING class the fix targets: a
|
||||||
|
* string attr whose schema default is `null`/absent and whose parseHTML
|
||||||
|
* coerces `"" -> default` (image/drawio `alt`+`title`, video `alt` via
|
||||||
|
* aria-label, pdf/attachment `name`, attachment `mime`). Set true to also
|
||||||
|
* drive the THIRD-STATE convergence case (see runConvergenceCase) for this
|
||||||
|
* attr. Attrs whose default is NOT null (e.g. embed `provider`, default "")
|
||||||
|
* or that are not `""`-coerced (control attrs) are left unset.
|
||||||
|
*/
|
||||||
|
emptyStringClass?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A node type + the attribute matrix to sweep for it. */
|
||||||
|
export interface NodeStabilitySpec {
|
||||||
|
/** Node type (e.g. "image", "video"). */
|
||||||
|
type: string;
|
||||||
|
/** Attributes always present on the node (e.g. `{ src: "/i.png" }`). */
|
||||||
|
baseAttrs?: Record<string, unknown>;
|
||||||
|
/** Attributes to sweep at default and non-default. */
|
||||||
|
attrMatrix: AttrMatrixEntry[];
|
||||||
|
/**
|
||||||
|
* Attributes whose numeric -> string coercion on round-trip is DOCUMENTED and
|
||||||
|
* intentional; compared modulo `String(x)` on both sides. Defaults to the
|
||||||
|
* converter's known sizing set.
|
||||||
|
*/
|
||||||
|
numericStringAttrs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single unstable finding, legible enough to tie a gate-lock to. */
|
||||||
|
export interface Instability {
|
||||||
|
type: string;
|
||||||
|
attr: string;
|
||||||
|
/** What the input authored: the literal value, or the ABSENT sentinel. */
|
||||||
|
authored: unknown | typeof ABSENT;
|
||||||
|
/** What the round-trip produced. */
|
||||||
|
got: unknown;
|
||||||
|
/** What a stable round-trip should have produced (authored value or default). */
|
||||||
|
expected: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One matrix cell's result. */
|
||||||
|
export interface ComboResult {
|
||||||
|
label: string;
|
||||||
|
authored: Record<string, unknown>;
|
||||||
|
/** RAW-contour instabilities on the node under test. */
|
||||||
|
raw: Instability[];
|
||||||
|
/** CANONICAL-contour divergence (path + values) or null when equal. */
|
||||||
|
canonical: { path: string; a: unknown; b: unknown } | null;
|
||||||
|
/** True when the node type failed to round-trip at all (structural loss). */
|
||||||
|
missing: boolean;
|
||||||
|
md: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole-matrix report for one node spec. */
|
||||||
|
export interface MatrixReport {
|
||||||
|
type: string;
|
||||||
|
combos: ComboResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sentinel marking an attribute the input did NOT author. */
|
||||||
|
export const ABSENT = Symbol("ABSENT");
|
||||||
|
|
||||||
|
const DEFAULT_NUMERIC_STRING_ATTRS = [
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"size",
|
||||||
|
"aspectRatio",
|
||||||
|
];
|
||||||
|
|
||||||
|
// The ProseMirror schema the converter targets — its attribute `default`s are
|
||||||
|
// the authoritative "what an absent attr should re-materialize as" oracle.
|
||||||
|
const schema = getSchema(docmostExtensions);
|
||||||
|
|
||||||
|
/** Read the schema default for every attribute of a node type. */
|
||||||
|
function schemaDefaults(type: string): Record<string, unknown> {
|
||||||
|
const specAttrs = (schema.nodes[type]?.spec?.attrs ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
{ default: unknown }
|
||||||
|
>;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(specAttrs)) out[k] = v.default;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the first node of a given type anywhere in a PM doc tree. */
|
||||||
|
function findFirst(node: any, type: string): any {
|
||||||
|
if (node && node.type === type) return node;
|
||||||
|
for (const child of node?.content ?? []) {
|
||||||
|
const hit = findFirst(child, type);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coerce a scalar for the documented numeric->string comparison. */
|
||||||
|
const numStr = (x: unknown): unknown => (x == null ? x : String(x));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate the cartesian product of the matrix: every attribute independently
|
||||||
|
* at its default (index 0) or non-default (index 1) pick. The all-default
|
||||||
|
* corner is included (the baseline). Small by construction (2^N over a handful
|
||||||
|
* of at-risk string attrs).
|
||||||
|
*/
|
||||||
|
function enumerateCombos(matrix: AttrMatrixEntry[]): number[][] {
|
||||||
|
let combos: number[][] = [[]];
|
||||||
|
for (let i = 0; i < matrix.length; i++) {
|
||||||
|
const next: number[][] = [];
|
||||||
|
for (const c of combos) {
|
||||||
|
next.push([...c, 0]);
|
||||||
|
next.push([...c, 1]);
|
||||||
|
}
|
||||||
|
combos = next;
|
||||||
|
}
|
||||||
|
return combos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the authored attrs for one combo pick vector. */
|
||||||
|
function authoredAttrs(
|
||||||
|
spec: NodeStabilitySpec,
|
||||||
|
picks: number[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const attrs: Record<string, unknown> = { ...(spec.baseAttrs ?? {}) };
|
||||||
|
spec.attrMatrix.forEach((entry, i) => {
|
||||||
|
if (picks[i] === 1) {
|
||||||
|
attrs[entry.attr] = entry.nonDefault;
|
||||||
|
} else if (entry.default !== undefined) {
|
||||||
|
attrs[entry.attr] = entry.default;
|
||||||
|
}
|
||||||
|
// default === undefined -> OMIT the attr entirely (the absent case).
|
||||||
|
});
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable label for a combo (which attrs are at non-default). */
|
||||||
|
function comboLabel(spec: NodeStabilitySpec, picks: number[]): string {
|
||||||
|
const on = spec.attrMatrix
|
||||||
|
.filter((_, i) => picks[i] === 1)
|
||||||
|
.map((e) => e.attr);
|
||||||
|
return on.length === 0 ? "<all-default>" : on.join("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full stability matrix for one node spec and return a structured
|
||||||
|
* report (does NOT throw — the caller asserts, so a failure can print the whole
|
||||||
|
* report). Every combo runs the real export->import pipeline once.
|
||||||
|
*/
|
||||||
|
export async function runStabilityMatrix(
|
||||||
|
spec: NodeStabilitySpec,
|
||||||
|
): Promise<MatrixReport> {
|
||||||
|
const numericStringAttrs = new Set(
|
||||||
|
spec.numericStringAttrs ?? DEFAULT_NUMERIC_STRING_ATTRS,
|
||||||
|
);
|
||||||
|
const defaults = schemaDefaults(spec.type);
|
||||||
|
const combos: ComboResult[] = [];
|
||||||
|
|
||||||
|
for (const picks of enumerateCombos(spec.attrMatrix)) {
|
||||||
|
const authored = authoredAttrs(spec, picks);
|
||||||
|
const doc = { type: "doc", content: [{ type: spec.type, attrs: authored }] };
|
||||||
|
const md = convertProseMirrorToMarkdown(doc);
|
||||||
|
const rt = await markdownToProseMirror(md);
|
||||||
|
const node = findFirst(rt, spec.type);
|
||||||
|
|
||||||
|
const result: ComboResult = {
|
||||||
|
label: comboLabel(spec, picks),
|
||||||
|
authored,
|
||||||
|
raw: [],
|
||||||
|
canonical: null,
|
||||||
|
missing: node == null,
|
||||||
|
md,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node != null) {
|
||||||
|
// RAW contour: every materialized attr must equal the authored value, or
|
||||||
|
// (for an absent attr) the schema default — modulo the documented numeric
|
||||||
|
// string coercion.
|
||||||
|
const rtAttrs = (node.attrs ?? {}) as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(rtAttrs)) {
|
||||||
|
const authoredHas = Object.prototype.hasOwnProperty.call(authored, key);
|
||||||
|
const expected = authoredHas ? authored[key] : defaults[key];
|
||||||
|
let got = rtAttrs[key];
|
||||||
|
let exp = expected;
|
||||||
|
if (numericStringAttrs.has(key)) {
|
||||||
|
got = numStr(got);
|
||||||
|
exp = numStr(exp);
|
||||||
|
}
|
||||||
|
if (firstDivergence(got, exp) !== null) {
|
||||||
|
result.raw.push({
|
||||||
|
type: spec.type,
|
||||||
|
attr: key,
|
||||||
|
authored: authoredHas ? authored[key] : ABSENT,
|
||||||
|
got: rtAttrs[key],
|
||||||
|
expected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CANONICAL contour: canonical forms deep-equal, modulo the same numeric
|
||||||
|
// string coercion (applied to both trees so a documented coercion is not
|
||||||
|
// counted as a divergence).
|
||||||
|
const ca = normalizeNumeric(canonicalizeContent(doc), numericStringAttrs);
|
||||||
|
const cb = normalizeNumeric(canonicalizeContent(rt), numericStringAttrs);
|
||||||
|
result.canonical = firstDivergence(ca, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
combos.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: spec.type, combos };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-copy a canonical tree, coercing the documented numeric->string attrs to
|
||||||
|
* their string form so an intentional `640 -> "640"` coercion is not reported
|
||||||
|
* as a canonical divergence. Only touches the listed attribute keys.
|
||||||
|
*/
|
||||||
|
function normalizeNumeric(node: any, attrs: Set<string>): any {
|
||||||
|
if (Array.isArray(node)) return node.map((n) => normalizeNumeric(n, attrs));
|
||||||
|
if (node === null || typeof node !== "object") return node;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(node)) {
|
||||||
|
if (key === "attrs" && node.attrs && typeof node.attrs === "object") {
|
||||||
|
const a: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(node.attrs)) {
|
||||||
|
a[k] = attrs.has(k) ? numStr(v) : v;
|
||||||
|
}
|
||||||
|
out.attrs = a;
|
||||||
|
} else {
|
||||||
|
out[key] = normalizeNumeric(node[key], attrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten a report to just its unstable combos (for a terse assertion). */
|
||||||
|
export function unstableCombos(report: MatrixReport): ComboResult[] {
|
||||||
|
return report.combos.filter(
|
||||||
|
(c) => c.missing || c.raw.length > 0 || c.canonical !== null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// THIRD STATE: an EXPLICITLY-STORED empty string on a string attr.
|
||||||
|
//
|
||||||
|
// The matrix above sweeps TWO states per string attr: absent/default and a
|
||||||
|
// non-default value — and asserts FIRST-pass byte-stability for both. There is
|
||||||
|
// a third, degenerate state the matrix does NOT cover: the attr stored as a
|
||||||
|
// LITERAL `""`. This is DISTINCT from "the node never had the attr": a user
|
||||||
|
// types an alt in the editor, then deletes it, and Tiptap's
|
||||||
|
// `updateAttributes({ alt: "" })` persists a literal `alt: ""` in the stored
|
||||||
|
// JSON. There is no absent-vs-"" distinction in the DOM once serialized, so the
|
||||||
|
// fix's `getAttribute("alt") || null` coercion canonicalizes BOTH to the
|
||||||
|
// default (`null`).
|
||||||
|
//
|
||||||
|
// Consequence — and this is CORRECT, not a bug: a doc carrying an explicit `""`
|
||||||
|
// converges to the default on the FIRST round-trip (a ONE-TIME diff: `"" ->
|
||||||
|
// null`), then is byte-stable from the SECOND round-trip on (idempotent). So
|
||||||
|
// this state must be pinned with a DIFFERENT contract than the matrix's:
|
||||||
|
// - do NOT assert first-pass byte-stability (the first pass legitimately
|
||||||
|
// changes `""` -> default), and
|
||||||
|
// - DO assert the first pass converges to the default AND the second pass is
|
||||||
|
// idempotent (rt2 deep-equals rt1).
|
||||||
|
//
|
||||||
|
// A future sync/QA pass diffing stored pages will see this one-time `"" -> null`
|
||||||
|
// normalization exactly once per affected node; it is the converter canon, not
|
||||||
|
// corruption, and must not be flagged as data loss.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Result of the third-state ("explicit empty string") convergence probe. */
|
||||||
|
export interface ConvergenceResult {
|
||||||
|
type: string;
|
||||||
|
attr: string;
|
||||||
|
/** The schema default the attr must converge to on pass 1 (null / absent). */
|
||||||
|
expectedDefault: unknown;
|
||||||
|
/** rt1's materialized value for the attr — must equal `expectedDefault`. */
|
||||||
|
firstPassValue: unknown;
|
||||||
|
/** True when the node round-tripped AND rt1 converged the attr to default. */
|
||||||
|
convergedToDefault: boolean;
|
||||||
|
/** rt1-vs-rt2 divergence; MUST be null (idempotent from pass 2 on). */
|
||||||
|
secondPassDivergence: { path: string; a: unknown; b: unknown } | null;
|
||||||
|
/** True when the node type failed to round-trip at all (structural loss). */
|
||||||
|
missing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round-trip a full PM doc through the real converter once. */
|
||||||
|
async function roundtripDoc(doc: any): Promise<any> {
|
||||||
|
return markdownToProseMirror(convertProseMirrorToMarkdown(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Third-state convergence probe for one string attr of the empty-string class.
|
||||||
|
*
|
||||||
|
* (a) builds a doc with the attr EXPLICITLY set to `""` (baseAttrs + `""`),
|
||||||
|
* (b) rt1 = roundtrip(doc); asserts rt1's attr equals the schema default — the
|
||||||
|
* documented ONE-TIME `"" -> default` normalization (NOT byte-stable vs the
|
||||||
|
* `""` input, so first-pass stability is deliberately NOT asserted here),
|
||||||
|
* (c) rt2 = roundtrip(rt1); asserts rt2 deep-equals rt1 — idempotent from the
|
||||||
|
* second round-trip on.
|
||||||
|
*
|
||||||
|
* Returns a structured result (does NOT throw) so the caller can assert and
|
||||||
|
* print. Reusable across the whole node family: drive it for every attr flagged
|
||||||
|
* `emptyStringClass` on every spec (see convergenceCasesFor / the test driver).
|
||||||
|
*/
|
||||||
|
export async function runConvergenceCase(
|
||||||
|
spec: NodeStabilitySpec,
|
||||||
|
attr: string,
|
||||||
|
): Promise<ConvergenceResult> {
|
||||||
|
const expectedDefault = schemaDefaults(spec.type)[attr];
|
||||||
|
|
||||||
|
// (a) The degenerate third state: attr persisted as a LITERAL "".
|
||||||
|
const authored = { ...(spec.baseAttrs ?? {}), [attr]: "" };
|
||||||
|
const doc = { type: "doc", content: [{ type: spec.type, attrs: authored }] };
|
||||||
|
|
||||||
|
// (b) First round-trip: "" must normalize to the default (a one-time diff).
|
||||||
|
const rt1 = await roundtripDoc(doc);
|
||||||
|
const node1 = findFirst(rt1, spec.type);
|
||||||
|
const firstPassValue = node1?.attrs?.[attr];
|
||||||
|
const convergedToDefault =
|
||||||
|
node1 != null && firstDivergence(firstPassValue, expectedDefault) === null;
|
||||||
|
|
||||||
|
// (c) Second round-trip: must be byte-stable (rt2 deep-equals rt1). We compare
|
||||||
|
// the WHOLE docs — both are converter OUTPUTS already in the same materialized
|
||||||
|
// form (numeric attrs are strings on both sides), so no numeric normalization
|
||||||
|
// is needed here, unlike the raw/canonical contours above.
|
||||||
|
const rt2 = node1 != null ? await roundtripDoc(rt1) : rt1;
|
||||||
|
const secondPassDivergence =
|
||||||
|
node1 != null ? firstDivergence(rt1, rt2) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: spec.type,
|
||||||
|
attr,
|
||||||
|
expectedDefault,
|
||||||
|
firstPassValue,
|
||||||
|
convergedToDefault,
|
||||||
|
secondPassDivergence,
|
||||||
|
missing: node1 == null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The attrs of a spec flagged as members of the empty-string class. */
|
||||||
|
export function convergenceCasesFor(spec: NodeStabilitySpec): string[] {
|
||||||
|
return spec.attrMatrix
|
||||||
|
.filter((e) => e.emptyStringClass)
|
||||||
|
.map((e) => e.attr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a convergence result honours the "converges once, then stable" contract. */
|
||||||
|
export function convergenceOk(r: ConvergenceResult): boolean {
|
||||||
|
return !r.missing && r.convergedToDefault && r.secondPassDivergence === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a convergence result as a legible one-liner for a failed assertion. */
|
||||||
|
export function formatConvergence(r: ConvergenceResult): string {
|
||||||
|
if (r.missing) return `${r.type}.${r.attr}: DID-NOT-ROUND-TRIP`;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (!r.convergedToDefault) {
|
||||||
|
parts.push(
|
||||||
|
`pass1 did NOT converge: got ${JSON.stringify(r.firstPassValue)} (expected default ${JSON.stringify(r.expectedDefault)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (r.secondPassDivergence) {
|
||||||
|
parts.push(
|
||||||
|
`pass2 NOT idempotent @ ${r.secondPassDivergence.path}: ${JSON.stringify(r.secondPassDivergence.a)} vs ${JSON.stringify(r.secondPassDivergence.b)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const status = parts.length === 0 ? "converges-once-then-stable" : parts.join("; ");
|
||||||
|
return `${r.type}.${r.attr}: ${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a report as a legible multi-line string for a failed assertion. */
|
||||||
|
export function formatReport(report: MatrixReport): string {
|
||||||
|
const lines: string[] = [`node "${report.type}":`];
|
||||||
|
for (const c of report.combos) {
|
||||||
|
const flags: string[] = [];
|
||||||
|
if (c.missing) flags.push("DID-NOT-ROUND-TRIP");
|
||||||
|
for (const i of c.raw) {
|
||||||
|
const authored =
|
||||||
|
i.authored === ABSENT ? "absent" : JSON.stringify(i.authored);
|
||||||
|
flags.push(
|
||||||
|
`RAW ${i.type}.${i.attr}: ${authored} -> ${JSON.stringify(i.got)} (expected ${JSON.stringify(i.expected)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (c.canonical) {
|
||||||
|
flags.push(
|
||||||
|
`CANON @ ${c.canonical.path}: ${JSON.stringify(c.canonical.a)} vs ${JSON.stringify(c.canonical.b)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const status = flags.length === 0 ? "stable" : flags.join("; ");
|
||||||
|
lines.push(` [${c.label}] ${status}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
runStabilityMatrix,
|
||||||
|
unstableCombos,
|
||||||
|
formatReport,
|
||||||
|
runConvergenceCase,
|
||||||
|
convergenceCasesFor,
|
||||||
|
convergenceOk,
|
||||||
|
formatConvergence,
|
||||||
|
type NodeStabilitySpec,
|
||||||
|
} from "./roundtrip-stability.helper.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Round-trip STABILITY matrix for image + the media family.
|
||||||
|
//
|
||||||
|
// Guards the "empty-string-vs-absent" churn class (GS-EDIT-REVERT family): a
|
||||||
|
// stored node authored WITHOUT a string attr (alt/title/caption/aria-label/...)
|
||||||
|
// must not gain a phantom `attr: ""` after `markdownToProseMirror(convert…)`.
|
||||||
|
// Each spec sweeps the at-risk string attrs at DEFAULT (absent) and at a real
|
||||||
|
// NON-default value; the helper asserts both the RAW round-trip (attrs equal the
|
||||||
|
// input's, modulo the documented numeric width/height/size/aspectRatio -> string
|
||||||
|
// coercion) and the CANONICAL round-trip (canonical forms deep-equal).
|
||||||
|
//
|
||||||
|
// The image + media family share the `align !== "center"` predicate and the
|
||||||
|
// `<!--name {…}-->` comment machinery, so one matrix guards the shared class.
|
||||||
|
// align is NOT part of this class (it round-trips correctly) and is not swept.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SPECS: NodeStabilitySpec[] = [
|
||||||
|
{
|
||||||
|
// Image carries the most at-risk string attrs. `alt` is the one marked
|
||||||
|
// materializes as `<img alt="">` on `` import (the real bug); title
|
||||||
|
// and caption are covered as the same class. attachmentId is a string attr
|
||||||
|
// that must stay absent when unset (control).
|
||||||
|
type: "image",
|
||||||
|
baseAttrs: { src: "/i.png" },
|
||||||
|
attrMatrix: [
|
||||||
|
{ attr: "alt", default: undefined, nonDefault: "a real alt text", emptyStringClass: true },
|
||||||
|
{ attr: "title", default: undefined, nonDefault: "a real title", emptyStringClass: true },
|
||||||
|
{ attr: "caption", default: undefined, nonDefault: "a real caption" },
|
||||||
|
{ attr: "attachmentId", default: undefined, nonDefault: "att-42" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Video's `alt` rides the `aria-label` attribute (media aria-label at risk).
|
||||||
|
type: "video",
|
||||||
|
baseAttrs: { src: "/v.mp4" },
|
||||||
|
attrMatrix: [
|
||||||
|
{ attr: "alt", default: undefined, nonDefault: "a clip", emptyStringClass: true },
|
||||||
|
{ attr: "attachmentId", default: undefined, nonDefault: "att-1" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Audio carries no alt/title; attachmentId is its only optional string attr.
|
||||||
|
type: "audio",
|
||||||
|
baseAttrs: { src: "/a.mp3" },
|
||||||
|
attrMatrix: [
|
||||||
|
{ attr: "attachmentId", default: undefined, nonDefault: "att-2" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// pdf: link-form media. `name` (filename) is its at-risk string attr.
|
||||||
|
type: "pdf",
|
||||||
|
baseAttrs: { src: "/d.pdf" },
|
||||||
|
attrMatrix: [
|
||||||
|
{ attr: "name", default: undefined, nonDefault: "report.pdf", emptyStringClass: true },
|
||||||
|
{ attr: "attachmentId", default: undefined, nonDefault: "att-3" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// attachment: link-form media (file card). `name` + `mime` string attrs.
|
||||||
|
type: "attachment",
|
||||||
|
baseAttrs: { url: "/f.zip" },
|
||||||
|
attrMatrix: [
|
||||||
|
{ attr: "name", default: undefined, nonDefault: "bundle.zip", emptyStringClass: true },
|
||||||
|
{ attr: "mime", default: undefined, nonDefault: "application/zip", emptyStringClass: true },
|
||||||
|
{ attr: "attachmentId", default: undefined, nonDefault: "att-4" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// embed: link-form media. `provider` is its at-risk string attr (schema
|
||||||
|
// default ""). embed's numeric width/height defaults (800/600) are a SEPARATE,
|
||||||
|
// documented limitation OUTSIDE the empty-string class: they are not in
|
||||||
|
// canonicalize's KNOWN_DEFAULTS, so an ABSENT width/height re-imports as the
|
||||||
|
// 800/600 default and diverges canonically (see the note in canonicalize.ts).
|
||||||
|
// That is canonicalize-owned and out of scope here, so we author the
|
||||||
|
// dimensions at their defaults (as real editor embeds carry them) to keep this
|
||||||
|
// guard focused on the empty-string/provider class.
|
||||||
|
// provider's schema default is "" (NOT null), so a re-imported "" is the
|
||||||
|
// correct value, not a phantom — it is outside the null-default empty-string
|
||||||
|
// class. We author it at its "" default (the default pick) so the sweep still
|
||||||
|
// asserts a non-default provider ("youtube") round-trips, without tripping the
|
||||||
|
// canonicalize KNOWN_DEFAULTS gap for embed's non-null defaults.
|
||||||
|
type: "embed",
|
||||||
|
baseAttrs: { src: "https://example.com/x", width: 800, height: 600 },
|
||||||
|
attrMatrix: [
|
||||||
|
{ attr: "provider", default: "", nonDefault: "youtube" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// drawio: image-form diagram. `title` + `alt` string attrs (data-title/-alt).
|
||||||
|
type: "drawio",
|
||||||
|
baseAttrs: { src: "blob:drawio" },
|
||||||
|
attrMatrix: [
|
||||||
|
{ attr: "title", default: undefined, nonDefault: "flow chart", emptyStringClass: true },
|
||||||
|
{ attr: "alt", default: undefined, nonDefault: "an alt", emptyStringClass: true },
|
||||||
|
{ attr: "attachmentId", default: undefined, nonDefault: "att-5" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// excalidraw: image-form diagram, same shared diagramAttributes set.
|
||||||
|
type: "excalidraw",
|
||||||
|
baseAttrs: { src: "blob:excalidraw" },
|
||||||
|
attrMatrix: [
|
||||||
|
{ attr: "title", default: undefined, nonDefault: "sketch", emptyStringClass: true },
|
||||||
|
{ attr: "alt", default: undefined, nonDefault: "an alt", emptyStringClass: true },
|
||||||
|
{ attr: "attachmentId", default: undefined, nonDefault: "att-6" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("round-trip stability matrix (image + media family)", () => {
|
||||||
|
for (const spec of SPECS) {
|
||||||
|
it(`${spec.type}: no attr materializes an empty-string / phantom value`, async () => {
|
||||||
|
const report = await runStabilityMatrix(spec);
|
||||||
|
const unstable = unstableCombos(report);
|
||||||
|
// On failure, print the WHOLE matrix so which (attr, value) combos are
|
||||||
|
// unstable is legible.
|
||||||
|
expect(unstable, `\n${formatReport(report)}\n`).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// THIRD STATE: an attr EXPLICITLY stored as a literal "" (GS-EDIT-REVERT: a user
|
||||||
|
// typed alt/title/name/... then deleted it, so Tiptap persisted `attr: ""` — a
|
||||||
|
// value DISTINCT from "attr was never set"). Unlike the absent case above, this
|
||||||
|
// state is NOT first-pass byte-stable: the fix's `"" -> default` coercion is a
|
||||||
|
// deliberate ONE-TIME normalization on the FIRST sync round-trip, stable
|
||||||
|
// thereafter. We therefore assert a DIFFERENT contract — "converges to default
|
||||||
|
// on pass 1, then idempotent from pass 2 on" — for every empty-string-class attr
|
||||||
|
// across the whole node family (image/video/pdf/attachment/drawio/excalidraw).
|
||||||
|
//
|
||||||
|
// IMPORTANT for a future sync/QA pass: the pass-1 `"" -> null` diff is the
|
||||||
|
// converter canon, not corruption. It appears at most once per affected node and
|
||||||
|
// must NOT be flagged as "the converter is losing/corrupting page data".
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("round-trip third state: explicit empty string converges once, then idempotent", () => {
|
||||||
|
for (const spec of SPECS) {
|
||||||
|
for (const attr of convergenceCasesFor(spec)) {
|
||||||
|
it(`${spec.type}.${attr}: "" normalizes to default on pass 1, byte-stable from pass 2`, async () => {
|
||||||
|
const r = await runConvergenceCase(spec, attr);
|
||||||
|
// Pass 1 must converge "" -> the schema default (the one-time diff) and
|
||||||
|
// pass 2 (roundtrip of pass-1 output) must be byte-stable. formatConvergence
|
||||||
|
// prints exactly which half failed.
|
||||||
|
expect(convergenceOk(r), `\n${formatConvergence(r)}\n`).toBe(true);
|
||||||
|
// Spell the contract out explicitly so the intent is legible in the test:
|
||||||
|
expect(r.convergedToDefault, `\n${formatConvergence(r)}\n`).toBe(true);
|
||||||
|
expect(r.firstPassValue).toEqual(r.expectedDefault);
|
||||||
|
expect(r.secondPassDivergence, `\n${formatConvergence(r)}\n`).toBeNull();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+297
-7
@@ -335,6 +335,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: 2.1.1
|
specifier: 2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
diff:
|
||||||
|
specifier: 8.0.3
|
||||||
|
version: 8.0.3
|
||||||
dompurify:
|
dompurify:
|
||||||
specifier: 3.4.1
|
specifier: 3.4.1
|
||||||
version: 3.4.1
|
version: 3.4.1
|
||||||
@@ -453,6 +456,9 @@ importers:
|
|||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: 6.0.1
|
specifier: 6.0.1
|
||||||
version: 6.0.1(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
version: 6.0.1(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
specifier: 4.1.6
|
||||||
|
version: 4.1.6(vitest@4.1.6)
|
||||||
eslint:
|
eslint:
|
||||||
specifier: 9.28.0
|
specifier: 9.28.0
|
||||||
version: 9.28.0(jiti@2.4.2)
|
version: 9.28.0(jiti@2.4.2)
|
||||||
@@ -497,7 +503,7 @@ importers:
|
|||||||
version: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
|
version: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.6
|
specifier: 4.1.6
|
||||||
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
|
|
||||||
apps/server:
|
apps/server:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -886,6 +892,13 @@ importers:
|
|||||||
marked:
|
marked:
|
||||||
specifier: 17.0.5
|
specifier: 17.0.5
|
||||||
version: 17.0.5
|
version: 17.0.5
|
||||||
|
devDependencies:
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
specifier: 4.1.6
|
||||||
|
version: 4.1.6(vitest@4.1.6)
|
||||||
|
vitest:
|
||||||
|
specifier: 4.1.6
|
||||||
|
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@27.4.0(@noble/hashes@2.0.1))(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
|
|
||||||
packages/git-sync:
|
packages/git-sync:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -941,6 +954,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20.0.0
|
specifier: ^20.0.0
|
||||||
version: 20.19.43
|
version: 20.19.43
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
specifier: 4.1.6
|
||||||
|
version: 4.1.6(vitest@4.1.6)
|
||||||
fast-check:
|
fast-check:
|
||||||
specifier: ^4.8.0
|
specifier: ^4.8.0
|
||||||
version: 4.8.0
|
version: 4.8.0
|
||||||
@@ -949,7 +965,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.6
|
specifier: 4.1.6
|
||||||
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
|
|
||||||
packages/mcp:
|
packages/mcp:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1013,6 +1029,9 @@ importers:
|
|||||||
marked:
|
marked:
|
||||||
specifier: ^17.0.1
|
specifier: ^17.0.1
|
||||||
version: 17.0.5
|
version: 17.0.5
|
||||||
|
re2:
|
||||||
|
specifier: ^1.21.0
|
||||||
|
version: 1.25.0
|
||||||
ws:
|
ws:
|
||||||
specifier: 8.20.1
|
specifier: 8.20.1
|
||||||
version: 8.20.1
|
version: 8.20.1
|
||||||
@@ -1095,7 +1114,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.6
|
specifier: 4.1.6
|
||||||
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -1567,10 +1586,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.29.7':
|
||||||
|
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@7.28.5':
|
'@babel/helper-validator-identifier@7.28.5':
|
||||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.29.7':
|
||||||
|
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/helper-validator-option@7.27.1':
|
'@babel/helper-validator-option@7.27.1':
|
||||||
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
|
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -1593,6 +1620,11 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/parser@7.29.7':
|
||||||
|
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.23.3':
|
'@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.23.3':
|
||||||
resolution: {integrity: sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==}
|
resolution: {integrity: sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -2082,9 +2114,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
|
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/types@7.29.7':
|
||||||
|
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@bcoe/v8-coverage@0.2.3':
|
'@bcoe/v8-coverage@0.2.3':
|
||||||
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
|
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
|
||||||
|
|
||||||
|
'@bcoe/v8-coverage@1.0.2':
|
||||||
|
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@borewit/text-codec@0.2.1':
|
'@borewit/text-codec@0.2.1':
|
||||||
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
||||||
|
|
||||||
@@ -2919,6 +2959,10 @@ packages:
|
|||||||
'@ioredis/commands@1.5.1':
|
'@ioredis/commands@1.5.1':
|
||||||
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
|
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
|
||||||
|
|
||||||
|
'@isaacs/fs-minipass@4.0.1':
|
||||||
|
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
'@istanbuljs/load-nyc-config@1.1.0':
|
'@istanbuljs/load-nyc-config@1.1.0':
|
||||||
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
|
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -5520,6 +5564,15 @@ packages:
|
|||||||
babel-plugin-react-compiler:
|
babel-plugin-react-compiler:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@vitest/coverage-v8@4.1.6':
|
||||||
|
resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@vitest/browser': 4.1.6
|
||||||
|
vitest: 4.1.6
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vitest/browser':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@vitest/expect@4.1.6':
|
'@vitest/expect@4.1.6':
|
||||||
resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==}
|
resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==}
|
||||||
|
|
||||||
@@ -5619,6 +5672,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==}
|
resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
abbrev@5.0.0:
|
||||||
|
resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==}
|
||||||
|
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||||
|
|
||||||
abstract-logging@2.0.1:
|
abstract-logging@2.0.1:
|
||||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||||
|
|
||||||
@@ -5805,6 +5862,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
ast-v8-to-istanbul@1.0.4:
|
||||||
|
resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==}
|
||||||
|
|
||||||
async-lock@1.4.1:
|
async-lock@1.4.1:
|
||||||
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
|
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
|
||||||
|
|
||||||
@@ -6086,6 +6146,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||||
engines: {node: '>= 14.16.0'}
|
engines: {node: '>= 14.16.0'}
|
||||||
|
|
||||||
|
chownr@3.0.0:
|
||||||
|
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
chrome-trace-event@1.0.3:
|
chrome-trace-event@1.0.3:
|
||||||
resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==}
|
resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -7042,6 +7106,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==}
|
resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==}
|
||||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||||
|
|
||||||
|
exponential-backoff@3.1.3:
|
||||||
|
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
||||||
|
|
||||||
express-rate-limit@8.2.2:
|
express-rate-limit@8.2.2:
|
||||||
resolution: {integrity: sha512-Ybv7bqtOgA914MLwaHWVFXMpMYeR1MQu/D+z2MaLYteqBsTIp9sY3AU7mGNLMJv8eLg8uQMpE20I+L2Lv49nSg==}
|
resolution: {integrity: sha512-Ybv7bqtOgA914MLwaHWVFXMpMYeR1MQu/D+z2MaLYteqBsTIp9sY3AU7mGNLMJv8eLg8uQMpE20I+L2Lv49nSg==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
@@ -7522,6 +7589,11 @@ packages:
|
|||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
|
install-artifact-from-github@1.6.0:
|
||||||
|
resolution: {integrity: sha512-wKsuzN8fy8QK7iEUqyWTQmvZ1QFGPn1xyl3/1iIIDthDjS7Hn9HoPwHlNakZirWbCsbad0lZMkr6Xfbpe1pUzw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -7709,6 +7781,10 @@ packages:
|
|||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
isexe@4.0.0:
|
||||||
|
resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
isomorphic.js@0.2.5:
|
isomorphic.js@0.2.5:
|
||||||
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
|
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
|
||||||
|
|
||||||
@@ -7732,6 +7808,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==}
|
resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
istanbul-reports@3.2.0:
|
||||||
|
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
iterare@1.2.1:
|
iterare@1.2.1:
|
||||||
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
|
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -7957,6 +8037,9 @@ packages:
|
|||||||
js-tiktoken@1.0.21:
|
js-tiktoken@1.0.21:
|
||||||
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
||||||
|
|
||||||
|
js-tokens@10.0.0:
|
||||||
|
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -8402,6 +8485,9 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
magicast@0.5.3:
|
||||||
|
resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
|
||||||
|
|
||||||
make-dir@2.1.0:
|
make-dir@2.1.0:
|
||||||
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
|
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -8544,6 +8630,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
|
|
||||||
|
minizlib@3.1.0:
|
||||||
|
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
mitt@3.0.1:
|
mitt@3.0.1:
|
||||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||||
|
|
||||||
@@ -8581,6 +8671,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
||||||
engines: {node: ^18.17.0 || >=20.5.0}
|
engines: {node: ^18.17.0 || >=20.5.0}
|
||||||
|
|
||||||
|
nan@2.28.0:
|
||||||
|
resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==}
|
||||||
|
|
||||||
nanoid@3.3.8:
|
nanoid@3.3.8:
|
||||||
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
|
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -8673,6 +8766,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
node-gyp@13.0.1:
|
||||||
|
resolution: {integrity: sha512-piOr0S10qy5THB+q5BdqkoOx65XL/tjTMUAit3vciPNp+snTOBnGunWH1Rz7XZUxf2T9uFrfT/Ty4+aC3yPeyg==}
|
||||||
|
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
node-int64@0.4.0:
|
node-int64@0.4.0:
|
||||||
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
||||||
|
|
||||||
@@ -8683,6 +8781,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==}
|
resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
nopt@10.0.1:
|
||||||
|
resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==}
|
||||||
|
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
normalize-path@3.0.0:
|
normalize-path@3.0.0:
|
||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -9202,6 +9305,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
proc-log@7.0.0:
|
||||||
|
resolution: {integrity: sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==}
|
||||||
|
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||||
|
|
||||||
process-nextick-args@2.0.1:
|
process-nextick-args@2.0.1:
|
||||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
@@ -9356,6 +9463,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
re2@1.25.0:
|
||||||
|
resolution: {integrity: sha512-mtxKjWS+VYIt2ijgt6ohEdwzNlGPom1whyaEKJD40cBc/wqkO1vJoOyK539Qb8Xa9m4GA6hiPGDIbW/d3egSRQ==}
|
||||||
|
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||||
|
|
||||||
react-clear-modal@2.0.18:
|
react-clear-modal@2.0.18:
|
||||||
resolution: {integrity: sha512-Aiv8Bw5NVm19tlUt3RLV2a1I/ya+UlyEZjREosn5G887nnusnefT+ls4AXkuP8XLn1KOah6DrM5MemV7cXgwWg==}
|
resolution: {integrity: sha512-Aiv8Bw5NVm19tlUt3RLV2a1I/ya+UlyEZjREosn5G887nnusnefT+ls4AXkuP8XLn1KOah6DrM5MemV7cXgwWg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -10030,6 +10141,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tar@7.5.19:
|
||||||
|
resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
terser-webpack-plugin@5.4.0:
|
terser-webpack-plugin@5.4.0:
|
||||||
resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==}
|
resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==}
|
||||||
engines: {node: '>= 10.13.0'}
|
engines: {node: '>= 10.13.0'}
|
||||||
@@ -10685,6 +10800,11 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
which@7.0.0:
|
||||||
|
resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==}
|
||||||
|
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -10810,6 +10930,10 @@ packages:
|
|||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
|
yallist@5.0.0:
|
||||||
|
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
yaml@1.10.3:
|
yaml@1.10.3:
|
||||||
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
|
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -11562,7 +11686,7 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/code-frame@7.29.0':
|
'@babel/code-frame@7.29.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
@@ -11710,8 +11834,12 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/helper-string-parser@7.27.1': {}
|
'@babel/helper-string-parser@7.27.1': {}
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.29.7': {}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@7.28.5': {}
|
'@babel/helper-validator-identifier@7.28.5': {}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.29.7': {}
|
||||||
|
|
||||||
'@babel/helper-validator-option@7.27.1': {}
|
'@babel/helper-validator-option@7.27.1': {}
|
||||||
|
|
||||||
'@babel/helper-wrap-function@7.22.20':
|
'@babel/helper-wrap-function@7.22.20':
|
||||||
@@ -11733,6 +11861,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.28.5
|
'@babel/types': 7.28.5
|
||||||
|
|
||||||
|
'@babel/parser@7.29.7':
|
||||||
|
dependencies:
|
||||||
|
'@babel/types': 7.29.7
|
||||||
|
|
||||||
'@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.23.3(@babel/core@7.28.5)':
|
'@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.23.3(@babel/core@7.28.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
@@ -12338,8 +12470,15 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@babel/types@7.29.7':
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-string-parser': 7.29.7
|
||||||
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
|
|
||||||
'@bcoe/v8-coverage@0.2.3': {}
|
'@bcoe/v8-coverage@0.2.3': {}
|
||||||
|
|
||||||
|
'@bcoe/v8-coverage@1.0.2': {}
|
||||||
|
|
||||||
'@borewit/text-codec@0.2.1': {}
|
'@borewit/text-codec@0.2.1': {}
|
||||||
|
|
||||||
'@braintree/sanitize-url@6.0.2': {}
|
'@braintree/sanitize-url@6.0.2': {}
|
||||||
@@ -13083,6 +13222,10 @@ snapshots:
|
|||||||
|
|
||||||
'@ioredis/commands@1.5.1': {}
|
'@ioredis/commands@1.5.1': {}
|
||||||
|
|
||||||
|
'@isaacs/fs-minipass@4.0.1':
|
||||||
|
dependencies:
|
||||||
|
minipass: 7.1.3
|
||||||
|
|
||||||
'@istanbuljs/load-nyc-config@1.1.0':
|
'@istanbuljs/load-nyc-config@1.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
camelcase: 5.3.1
|
camelcase: 5.3.1
|
||||||
@@ -13323,7 +13466,7 @@ snapshots:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
'@jridgewell/trace-mapping@0.3.9':
|
'@jridgewell/trace-mapping@0.3.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -15998,6 +16141,20 @@ snapshots:
|
|||||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||||
vite: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
|
vite: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
||||||
|
'@vitest/coverage-v8@4.1.6(vitest@4.1.6)':
|
||||||
|
dependencies:
|
||||||
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
|
'@vitest/utils': 4.1.6
|
||||||
|
ast-v8-to-istanbul: 1.0.4
|
||||||
|
istanbul-lib-coverage: 3.2.2
|
||||||
|
istanbul-lib-report: 3.0.1
|
||||||
|
istanbul-reports: 3.2.0
|
||||||
|
magicast: 0.5.3
|
||||||
|
obug: 2.1.1
|
||||||
|
std-env: 4.1.0
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@27.4.0(@noble/hashes@2.0.1))(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
|
|
||||||
'@vitest/expect@4.1.6':
|
'@vitest/expect@4.1.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
@@ -16023,6 +16180,14 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
|
vite: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
||||||
|
'@vitest/mocker@4.1.6(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 4.1.6
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.21
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
||||||
'@vitest/pretty-format@4.1.6':
|
'@vitest/pretty-format@4.1.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
@@ -16142,6 +16307,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
|
|
||||||
|
abbrev@5.0.0: {}
|
||||||
|
|
||||||
abstract-logging@2.0.1: {}
|
abstract-logging@2.0.1: {}
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
@@ -16335,6 +16502,12 @@ snapshots:
|
|||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
|
ast-v8-to-istanbul@1.0.4:
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
js-tokens: 10.0.0
|
||||||
|
|
||||||
async-lock@1.4.1: {}
|
async-lock@1.4.1: {}
|
||||||
|
|
||||||
async-mutex@0.5.0:
|
async-mutex@0.5.0:
|
||||||
@@ -16700,6 +16873,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
readdirp: 4.0.2
|
readdirp: 4.0.2
|
||||||
|
|
||||||
|
chownr@3.0.0: {}
|
||||||
|
|
||||||
chrome-trace-event@1.0.3: {}
|
chrome-trace-event@1.0.3: {}
|
||||||
|
|
||||||
ci-info@4.4.0: {}
|
ci-info@4.4.0: {}
|
||||||
@@ -17815,6 +17990,8 @@ snapshots:
|
|||||||
jest-mock: 30.3.0
|
jest-mock: 30.3.0
|
||||||
jest-util: 30.3.0
|
jest-util: 30.3.0
|
||||||
|
|
||||||
|
exponential-backoff@3.1.3: {}
|
||||||
|
|
||||||
express-rate-limit@8.2.2(express@5.2.1):
|
express-rate-limit@8.2.2(express@5.2.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
@@ -18350,6 +18527,8 @@ snapshots:
|
|||||||
|
|
||||||
inherits@2.0.4: {}
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
|
install-artifact-from-github@1.6.0: {}
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -18521,6 +18700,8 @@ snapshots:
|
|||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
isexe@4.0.0: {}
|
||||||
|
|
||||||
isomorphic.js@0.2.5: {}
|
isomorphic.js@0.2.5: {}
|
||||||
|
|
||||||
istanbul-lib-coverage@3.2.2: {}
|
istanbul-lib-coverage@3.2.2: {}
|
||||||
@@ -18554,6 +18735,11 @@ snapshots:
|
|||||||
html-escaper: 2.0.2
|
html-escaper: 2.0.2
|
||||||
istanbul-lib-report: 3.0.1
|
istanbul-lib-report: 3.0.1
|
||||||
|
|
||||||
|
istanbul-reports@3.2.0:
|
||||||
|
dependencies:
|
||||||
|
html-escaper: 2.0.2
|
||||||
|
istanbul-lib-report: 3.0.1
|
||||||
|
|
||||||
iterare@1.2.1: {}
|
iterare@1.2.1: {}
|
||||||
|
|
||||||
iterator.prototype@1.1.5:
|
iterator.prototype@1.1.5:
|
||||||
@@ -18964,6 +19150,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
|
|
||||||
|
js-tokens@10.0.0: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@3.14.2:
|
js-yaml@3.14.2:
|
||||||
@@ -19400,6 +19588,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
magicast@0.5.3:
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.7
|
||||||
|
'@babel/types': 7.29.7
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
make-dir@2.1.0:
|
make-dir@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
pify: 4.0.1
|
pify: 4.0.1
|
||||||
@@ -19537,6 +19731,10 @@ snapshots:
|
|||||||
|
|
||||||
minipass@7.1.3: {}
|
minipass@7.1.3: {}
|
||||||
|
|
||||||
|
minizlib@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
minipass: 7.1.3
|
||||||
|
|
||||||
mitt@3.0.1: {}
|
mitt@3.0.1: {}
|
||||||
|
|
||||||
mlly@1.8.0:
|
mlly@1.8.0:
|
||||||
@@ -19581,6 +19779,8 @@ snapshots:
|
|||||||
|
|
||||||
mute-stream@2.0.0: {}
|
mute-stream@2.0.0: {}
|
||||||
|
|
||||||
|
nan@2.28.0: {}
|
||||||
|
|
||||||
nanoid@3.3.8: {}
|
nanoid@3.3.8: {}
|
||||||
|
|
||||||
nanoid@4.0.2: {}
|
nanoid@4.0.2: {}
|
||||||
@@ -19641,12 +19841,29 @@ snapshots:
|
|||||||
|
|
||||||
node-gyp-build@4.8.4: {}
|
node-gyp-build@4.8.4: {}
|
||||||
|
|
||||||
|
node-gyp@13.0.1:
|
||||||
|
dependencies:
|
||||||
|
env-paths: 2.2.1
|
||||||
|
exponential-backoff: 3.1.3
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
nopt: 10.0.1
|
||||||
|
proc-log: 7.0.0
|
||||||
|
semver: 7.7.4
|
||||||
|
tar: 7.5.19
|
||||||
|
tinyglobby: 0.2.15
|
||||||
|
undici: 7.24.0
|
||||||
|
which: 7.0.0
|
||||||
|
|
||||||
node-int64@0.4.0: {}
|
node-int64@0.4.0: {}
|
||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
nodemailer@8.0.5: {}
|
nodemailer@8.0.5: {}
|
||||||
|
|
||||||
|
nopt@10.0.1:
|
||||||
|
dependencies:
|
||||||
|
abbrev: 5.0.0
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
notepack.io@3.0.1: {}
|
notepack.io@3.0.1: {}
|
||||||
@@ -20251,6 +20468,8 @@ snapshots:
|
|||||||
|
|
||||||
prismjs@1.30.0: {}
|
prismjs@1.30.0: {}
|
||||||
|
|
||||||
|
proc-log@7.0.0: {}
|
||||||
|
|
||||||
process-nextick-args@2.0.1: {}
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
process-warning@4.0.0: {}
|
process-warning@4.0.0: {}
|
||||||
@@ -20505,6 +20724,12 @@ snapshots:
|
|||||||
iconv-lite: 0.7.2
|
iconv-lite: 0.7.2
|
||||||
unpipe: 1.0.0
|
unpipe: 1.0.0
|
||||||
|
|
||||||
|
re2@1.25.0:
|
||||||
|
dependencies:
|
||||||
|
install-artifact-from-github: 1.6.0
|
||||||
|
nan: 2.28.0
|
||||||
|
node-gyp: 13.0.1
|
||||||
|
|
||||||
react-clear-modal@2.0.18(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
react-clear-modal@2.0.18(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
@@ -21288,6 +21513,14 @@ snapshots:
|
|||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
|
tar@7.5.19:
|
||||||
|
dependencies:
|
||||||
|
'@isaacs/fs-minipass': 4.0.1
|
||||||
|
chownr: 3.0.0
|
||||||
|
minipass: 7.1.3
|
||||||
|
minizlib: 3.1.0
|
||||||
|
yallist: 5.0.0
|
||||||
|
|
||||||
terser-webpack-plugin@5.4.0(@swc/core@1.5.25(@swc/helpers@0.5.5))(webpack@5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5))):
|
terser-webpack-plugin@5.4.0(@swc/core@1.5.25(@swc/helpers@0.5.5))(webpack@5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5))):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
@@ -21757,7 +21990,25 @@ snapshots:
|
|||||||
tsx: 4.21.0
|
tsx: 4.21.0
|
||||||
yaml: 2.8.3
|
yaml: 2.8.3
|
||||||
|
|
||||||
vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)):
|
vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3):
|
||||||
|
dependencies:
|
||||||
|
lightningcss: 1.32.0
|
||||||
|
picomatch: 4.0.4
|
||||||
|
postcss: 8.5.14
|
||||||
|
rolldown: 1.0.0-rc.12
|
||||||
|
tinyglobby: 0.2.15
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.5.0
|
||||||
|
esbuild: 0.28.0
|
||||||
|
fsevents: 2.3.3
|
||||||
|
jiti: 2.4.2
|
||||||
|
less: 4.2.0
|
||||||
|
sugarss: 5.0.1(postcss@8.5.14)
|
||||||
|
terser: 5.39.0
|
||||||
|
tsx: 4.21.0
|
||||||
|
yaml: 2.8.3
|
||||||
|
|
||||||
|
vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.6
|
'@vitest/expect': 4.1.6
|
||||||
'@vitest/mocker': 4.1.6(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
'@vitest/mocker': 4.1.6(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
@@ -21782,12 +22033,13 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@types/node': 20.19.43
|
'@types/node': 20.19.43
|
||||||
|
'@vitest/coverage-v8': 4.1.6(vitest@4.1.6)
|
||||||
happy-dom: 20.8.9
|
happy-dom: 20.8.9
|
||||||
jsdom: 25.0.0
|
jsdom: 25.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- msw
|
||||||
|
|
||||||
vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)):
|
vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.6
|
'@vitest/expect': 4.1.6
|
||||||
'@vitest/mocker': 4.1.6(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
'@vitest/mocker': 4.1.6(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
@@ -21812,11 +22064,43 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@types/node': 22.19.1
|
'@types/node': 22.19.1
|
||||||
|
'@vitest/coverage-v8': 4.1.6(vitest@4.1.6)
|
||||||
happy-dom: 20.8.9
|
happy-dom: 20.8.9
|
||||||
jsdom: 25.0.0
|
jsdom: 25.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- msw
|
||||||
|
|
||||||
|
vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@27.4.0(@noble/hashes@2.0.1))(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)):
|
||||||
|
dependencies:
|
||||||
|
'@vitest/expect': 4.1.6
|
||||||
|
'@vitest/mocker': 4.1.6(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
|
'@vitest/pretty-format': 4.1.6
|
||||||
|
'@vitest/runner': 4.1.6
|
||||||
|
'@vitest/snapshot': 4.1.6
|
||||||
|
'@vitest/spy': 4.1.6
|
||||||
|
'@vitest/utils': 4.1.6
|
||||||
|
es-module-lexer: 2.1.0
|
||||||
|
expect-type: 1.3.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
obug: 2.1.1
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.4
|
||||||
|
std-env: 4.1.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 1.1.2
|
||||||
|
tinyglobby: 0.2.15
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
vite: 8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@opentelemetry/api': 1.9.0
|
||||||
|
'@types/node': 25.5.0
|
||||||
|
'@vitest/coverage-v8': 4.1.6(vitest@4.1.6)
|
||||||
|
happy-dom: 20.8.9
|
||||||
|
jsdom: 27.4.0(@noble/hashes@2.0.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- msw
|
||||||
|
|
||||||
void-elements@3.1.0: {}
|
void-elements@3.1.0: {}
|
||||||
|
|
||||||
vscode-jsonrpc@8.2.0: {}
|
vscode-jsonrpc@8.2.0: {}
|
||||||
@@ -21977,6 +22261,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
which@7.0.0:
|
||||||
|
dependencies:
|
||||||
|
isexe: 4.0.0
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
@@ -22074,6 +22362,8 @@ snapshots:
|
|||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
|
yallist@5.0.0: {}
|
||||||
|
|
||||||
yaml@1.10.3: {}
|
yaml@1.10.3: {}
|
||||||
|
|
||||||
yaml@2.8.3: {}
|
yaml@2.8.3: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user