Compare commits

...

7 Commits

Author SHA1 Message Date
agent_coder d36021a111 fix(comment): clear resolved mark only after reopen confirmed
Move the inline ProseMirror comment-mark clear out of the Undo toast's
onClick and into the reopen mutation's onSuccess branch. Clearing it
eagerly flipped the collab mark to unresolved before the reopen result
was known: on a non-404 failure the RQ cache rolls back to resolved but
the doc kept an active highlight the panel treats as resolved, and a
comment refetch can't heal a divergence that lives in the collab doc.
Mirrors the 404 branch's editor-liveness guard/try-catch and the safe
await-then-mark pattern in comment-list-item. The button-triggered
reopen already sets the mark, so the onSuccess call is an idempotent
no-op there.

Tests: reopen failure (500) leaves the mark untouched; null editorRef
on the success path degrades gracefully.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 06:51:38 +03:00
agent_coder 12ed3a0332 feat(comment): undo button in resolve toast + sort resolved tab by resolve time
Part 1 (#542): add an inline Undo (reopen) button to the resolve success
toast in useResolveCommentMutation. Undo re-invokes the same mutation via a
self-ref (declared before useMutation, read at click time — no cycle) and
clears the inline comment mark through an editor ref (survives the originating
CommentListItem unmounting). A closured `done` flag guards a fast double-click
(notifications.hide is async). Reopen keeps the plain toast (no Undo).

onError gains a terminal 404 branch: drop the comment from the cache and
unsetComment its orphaned mark (no rollback → no phantom in Resolved, neutral
"Comment no longer exists"). The generic-failure copy is now directional
(resolve vs. re-open). Autoclose policy const RESOLVE_UNDO_AUTOCLOSE_MS=10000.

Part 2: sort the Resolved tab by resolvedAt DESC via an exported
sortResolvedByResolvedAt helper; coerce with new Date(...) because resolvedAt
is an ISO string at runtime (Date only in the optimistic window).

i18n: add "Failed to re-open comment" and "Comment no longer exists" to
en-US/ru-RU. Tests: 6 resolve-undo cases + 4 sort cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:45:42 +03:00
agent_vscode 03eafa6c68 Merge remote-tracking branch 'gitea/develop' into develop 2026-07-12 05:16:51 +03:00
agent_vscode a42f1ead48 test(ai-chat): align #332 deferred-tool test with #490 cross-turn persistence
The #332 integration test (ai-chat-stream.int-spec.ts) asserted a deferred
tool activated in one turn does NOT leak into the next ("cold start per
turn"). #490 (f5bbfdb2) deliberately reversed that: it persists the
activation set into chat metadata.activatedTools and seeds the next turn
from it, so the model need not re-run loadTools. The test only surfaced now
because the token-estimate CI fix let the integration step run again.

Adopt #490 persistence as the intended behavior:
- flip the turn-2 assertion to expect createPage IS active on the fresh
  turn's first step (seeded from metadata); keep turn-1 cold-start assertions.
- rewrite the test docstring, describe/it titles and comments accordingly.
- fix two stale "not persisted / per-turn" comments in ai-chat.service.ts
  (prepareAgentStep + the streaming-loop activation block); no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:16:47 +03:00
vvzvlad b7a3ec227d Merge pull request 'fix(drawio): content= — entity-XML вместо base64 (кириллица в редакторе) (#507)' (#521) from fix/507-drawio-cyrillic into develop
Reviewed-on: #521
2026-07-12 05:08:57 +03:00
agent_coder 846341d7d4 fix(drawio): encode literal tab/newline/CR in content= as numeric char-refs (#507 review)
Review follow-up to the base64→entity-XML content= switch. The whole mxfile XML
now lives in one content="..." attribute; XML attribute-value normalization
collapses a LITERAL tab/newline/CR to a single space on DOM read (jsdom and the
real draw.io editor alike), silently flattening multi-line labels and
tab-bearing values that the old base64 form stored verbatim.

Finding 1 (data-loss): both encode paths — buildDrawioSvg's xmlEscape (mcp) and
the import service's escape — now append &#x9;/&#xa;/&#xd; after the four
&<>" replaces (numeric char-refs survive normalization, as draw.io's own export
does). The extractContentAttr regex fallback now decodes those char-refs (hex
case-insensitive plus decimal &#9;/&#10;/&#13;) so it agrees with the DOM path;
&amp; stays decoded last so an escaped &amp;#x9; reads back as literal text.

Finding 2 (dedup): the server's private xmlEscapeAttr is replaced by the shared
htmlEscape helper (& < > " ' — a strict superset, the extra ' is harmless in a
"-delimited value) wrapped in xmlEscapeContent, which adds the three control-char
char-refs on top (htmlEscape does not escape them).

Finding 3 (docs): narrow the CHANGELOG healing claim — only a diagram still
holding its original correct-UTF-8 base64 (not yet opened/autosaved) is
recoverable; one already opened in the editor persisted mojibake at rest and its
text is lost.

Tests: new mcp round-trip test with literal tab/newline/CR in a value (DOM path,
byte-stable) plus a fallback-branch test forcing a malformed wrapper so both
decode paths are proven to agree; new server spec asserting char-ref encoding.
Mutation-checked: dropping the encode replaces reddens both new mcp tests;
dropping only the fallback decode reddens just the fallback test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:05:52 +03:00
agent_coder 32e10ca6d3 fix(drawio): content= пишется entity-XML, не base64 — кириллица не мойбейкает в редакторе (#507)
Реviewer-filed баг: draw.io-редактор декодит base64 content= как Latin-1
(atob-семантика) → кириллица разваливается в мойбейк, автосейв редактора
персистит порчу и убивает превью. Нативная форма draw.io — entity-encoded
mxfile-XML (content="&lt;mxfile…"), DOM-декодится как UTF-8.

Фикс двух write-путей: buildDrawioSvg (mcp, все create/update) и
createDrawioSvg (server Confluence-импорт) теперь XML-эскейпят content=
вместо base64. Декодер уже различает startsWith("&lt;") vs base64 — обе формы
читаются, старые base64-файлы открываются (back-compat), byte-stable
round-trip. CHANGELOG + заметка про лечение старых диаграмм (drawioGet→Update).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 05:05:52 +03:00
13 changed files with 915 additions and 47 deletions
+19
View File
@@ -392,6 +392,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`@docmost/token-estimate` package) so they can never diverge. Deferred-tool
activation is also cached in the chat metadata to avoid re-resolving it each
turn. (#490)
- **Cyrillic (and any non-ASCII) draw.io labels no longer turn into mojibake
when a diagram is opened in the draw.io editor.** Agent-created diagrams
(`drawioCreate`) and Confluence-imported diagrams stored their model in the
SVG's `content=` attribute as base64; the draw.io editor decodes that via
Latin-1 `atob` (no UTF-8 step), so every non-ASCII char (e.g. `Старт-бит`,
`ё`, ``) split into garbage and the editor's autosave then persisted the
corrupted model, breaking the page preview too. Both write paths
(`buildDrawioSvg`, the import service's `createDrawioSvg`) now write `content=`
as XML-entity-escaped mxfile XML — draw.io's own native form, decoded by the
DOM as UTF-8 — so labels open intact. The decoder reads both the new
entity-encoded form and the old base64 form, so existing diagrams still open.
*Healing pre-fix diagrams:* only a diagram that still holds its original
(correct-UTF-8) base64 — i.e. one not yet opened/autosaved in the draw.io
editor — can be repaired in place by `drawioGet` → `drawioUpdate` with the
same XML (rewrites the attachment in the new form); no migration script is
needed. A diagram that was already opened in the editor persisted the
mojibake at rest, so `drawioGet` reads the already-corrupted text and
`drawioUpdate` faithfully rewrites it — that text is lost and is not
recoverable by a rewrite. (#507)
- **A chat with one malformed message part no longer 500s on every turn, and a
failed send no longer duplicates the user's message.** Incoming client parts
are now whitelisted to `text` (a forged tool-result part can no longer reach
@@ -239,6 +239,8 @@
"Comment re-opened successfully": "Comment re-opened successfully",
"Comment unresolved successfully": "Comment unresolved successfully",
"Failed to resolve comment": "Failed to resolve comment",
"Failed to re-open comment": "Failed to re-open comment",
"Comment no longer exists": "Comment no longer exists",
"Resolve comment": "Resolve comment",
"Unresolve comment": "Unresolve comment",
"Resolve Comment Thread": "Resolve Comment Thread",
@@ -239,6 +239,8 @@
"Comment re-opened successfully": "Комментарий успешно открыт повторно",
"Comment unresolved successfully": "Комментарий успешно переведён в нерешённые",
"Failed to resolve comment": "Не удалось разрешить комментарий",
"Failed to re-open comment": "Не удалось переоткрыть комментарий",
"Comment no longer exists": "Комментарий больше не существует",
"Resolve comment": "Решить комментарий",
"Unresolve comment": "Снять статус решённого с комментария",
"Resolve Comment Thread": "Решить ветку комментариев",
@@ -27,11 +27,15 @@ vi.mock("@/features/space/queries/space-query.ts", () => ({
import {
buildChildrenByParent,
CommentEditorWithActions,
sortResolvedByResolvedAt,
} from "./comment-list-with-tabs";
const c = (id: string, parentCommentId: string | null = null): IComment =>
({ id, parentCommentId }) as IComment;
const resolvedAtComment = (id: string, resolvedAt: unknown): IComment =>
({ id, resolvedAt }) as unknown as IComment;
describe("buildChildrenByParent (childrenByParent grouping)", () => {
it("returns an empty map for undefined or empty input", () => {
expect(buildChildrenByParent(undefined).size).toBe(0);
@@ -71,6 +75,48 @@ describe("buildChildrenByParent (childrenByParent grouping)", () => {
});
});
describe("sortResolvedByResolvedAt (Resolved tab order, #542)", () => {
it("orders by resolvedAt DESC — newest resolve first — with ISO-STRING values", () => {
// At runtime resolvedAt is an ISO string (axios JSON / WS subscription), so
// the sort must coerce with new Date(...) before .getTime().
const older = resolvedAtComment("older", "2026-07-10T10:00:00.000Z");
const newest = resolvedAtComment("newest", "2026-07-12T10:00:00.000Z");
const middle = resolvedAtComment("middle", "2026-07-11T10:00:00.000Z");
const out = sortResolvedByResolvedAt([older, newest, middle]);
expect(out.map((x) => x.id)).toEqual(["newest", "middle", "older"]);
});
it("also handles Date instances (optimistic onMutate window)", () => {
const older = resolvedAtComment("older", new Date("2026-01-01T00:00:00Z"));
const newer = resolvedAtComment("newer", new Date("2026-06-01T00:00:00Z"));
expect(sortResolvedByResolvedAt([older, newer]).map((x) => x.id)).toEqual([
"newer",
"older",
]);
});
it("does not mutate the input array", () => {
const a = resolvedAtComment("a", "2026-01-01T00:00:00.000Z");
const b = resolvedAtComment("b", "2026-02-01T00:00:00.000Z");
const input = [a, b];
sortResolvedByResolvedAt(input);
expect(input.map((x) => x.id)).toEqual(["a", "b"]);
});
it("keeps stable order for equal resolvedAt timestamps", () => {
const ts = "2026-03-03T03:03:03.000Z";
const x = resolvedAtComment("x", ts);
const y = resolvedAtComment("y", ts);
const z = resolvedAtComment("z", ts);
expect(sortResolvedByResolvedAt([x, y, z]).map((c) => c.id)).toEqual([
"x",
"y",
"z",
]);
});
});
function renderReplyEditor() {
return render(
<MantineProvider>
@@ -53,6 +53,22 @@ export function buildChildrenByParent(
return m;
}
// Sort the Resolved tab by resolve time, newest first, on a COPY (never mutate
// the react-query cache array). `resolvedAt` is typed `Date` but at runtime it
// is an ISO STRING (from the axios-JSON onSuccess and the WS subscription) — a
// real Date only during the optimistic onMutate window — so it MUST be coerced
// with `new Date(...)` before `.getTime()`, or a raw `.getTime()` on the string
// throws / yields NaN. ES2019's stable sort preserves order for equal
// timestamps. Callers pass a list already filtered to a truthy `resolvedAt`, so
// the non-null assertion is safe.
// Exported for unit testing.
export function sortResolvedByResolvedAt(resolved: IComment[]): IComment[] {
return [...resolved].sort(
(a, b) =>
new Date(b.resolvedAt!).getTime() - new Date(a.resolvedAt!).getTime(),
);
}
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
@@ -91,7 +107,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
(comment: IComment) => comment.resolvedAt,
);
return { activeComments: active, resolvedComments: resolved };
return {
activeComments: active,
resolvedComments: sortResolvedByResolvedAt(resolved),
};
}, [comments]);
// Index replies by their parent once, instead of an O(n^2) filter per thread.
@@ -0,0 +1,353 @@
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 resolve/reopen mutation (#542): the Undo-in-toast reopen and
* its double-click guard, the terminal 404 branch (drop from cache + clear the
* inline mark, no rollback), and the directional error copy.
*/
// A fake TipTap editor injected via the mocked pageEditorAtom, so we can assert
// the mutation clears the inline comment mark (unsetComment / setCommentResolved).
const editorMock = vi.hoisted(() => ({
current: {
isDestroyed: false,
commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() },
} as {
isDestroyed: boolean;
commands: {
unsetComment: (id: string) => void;
setCommentResolved: (id: string, v: boolean) => void;
};
} | null,
}));
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn(), hide: vi.fn() },
}));
vi.mock("jotai", () => ({
atom: (v: unknown) => v,
useAtomValue: () => editorMock.current,
}));
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 { resolveComment } from "@/features/comment/services/comment-service";
import {
useResolveCommentMutation,
RESOLVE_UNDO_AUTOCLOSE_MS,
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(),
resolvedAt: null,
...over,
}) as IComment;
// Pull the inline Undo button's onClick out of the success toast's message tree.
function undoOnClickFromToast(): () => void {
const call = vi
.mocked(notifications.show)
.mock.calls.map((c) => c[0])
.find((arg: any) => arg?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS);
expect(call).toBeTruthy();
const message: any = (call as any).message;
// message = Group( Text, Button ); grab the Button element's onClick.
const children = message.props.children as any[];
const button = children[1];
return button.props.onClick;
}
describe("useResolveCommentMutation — Undo toast (#542)", () => {
beforeEach(() => {
vi.clearAllMocks();
editorMock.current = {
isDestroyed: false,
commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() },
};
});
it("resolve shows an Undo toast with autoClose=10000ms; reopen shows NO Undo", async () => {
vi.mocked(resolveComment).mockImplementation(async (data) =>
comment({
resolvedAt: data.resolved ? (new Date() as any) : null,
}),
);
const { wrapper } = seededClient(comment());
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current.mutateAsync({
commentId: "c-1",
pageId: PAGE_ID,
resolved: true,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const resolveToast = vi
.mocked(notifications.show)
.mock.calls.map((c) => c[0])
.find((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS);
expect(resolveToast).toBeTruthy();
expect((resolveToast as any).id).toBe("resolve-undo-c-1");
expect((resolveToast as any).autoClose).toBe(10000);
// Now a reopen → plain toast, no autoClose/Undo, no id.
vi.clearAllMocks();
await result.current.mutateAsync({
commentId: "c-1",
pageId: PAGE_ID,
resolved: false,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]);
expect(
calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS),
).toBe(false);
expect(calls).toContainEqual({ message: "Comment re-opened successfully" });
});
it("double/fast Undo click fires reopen EXACTLY once (guard)", async () => {
vi.mocked(resolveComment).mockImplementation(async (data) =>
comment({ resolvedAt: data.resolved ? (new Date() as any) : null }),
);
const { wrapper } = seededClient(comment());
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current.mutateAsync({
commentId: "c-1",
pageId: PAGE_ID,
resolved: true,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const onClick = undoOnClickFromToast();
// Fire twice synchronously (notifications.hide is not synchronous).
onClick();
onClick();
await waitFor(() => {
const reopenCalls = vi
.mocked(resolveComment)
.mock.calls.filter(([d]) => d.resolved === false);
expect(reopenCalls).toHaveLength(1);
});
// The mark was cleared once via setCommentResolved(id, false).
expect(editorMock.current!.commands.setCommentResolved).toHaveBeenCalledWith(
"c-1",
false,
);
// The toast was hidden.
expect(notifications.hide).toHaveBeenCalledWith("resolve-undo-c-1");
});
it("404 → drops the comment from cache, clears the inline mark, no rollback, no Undo", async () => {
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } });
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
.catch(() => undefined);
await waitFor(() => expect(result.current.isError).toBe(true));
// Removed from cache (NOT rolled back to a phantom).
expect(items(queryClient)).toHaveLength(0);
// Inline mark cleared via unsetComment (mandatory — no panel row left to do it).
expect(editorMock.current!.commands.unsetComment).toHaveBeenCalledWith(
"c-1",
);
// Neutral message, red, and crucially NOT the success copy and NO Undo toast.
expect(notifications.show).toHaveBeenCalledWith({
message: "Comment no longer exists",
color: "red",
});
const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]);
expect(
calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS),
).toBe(false);
});
it("404 does not crash when the editor is gone (read-only / panel closed)", async () => {
editorMock.current = null;
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } });
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
.catch(() => undefined);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(items(queryClient)).toHaveLength(0);
expect(notifications.show).toHaveBeenCalledWith({
message: "Comment no longer exists",
color: "red",
});
});
it("non-404 error on REOPEN shows 'Failed to re-open comment' and rolls back", async () => {
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
// Seed a RESOLVED comment (the reopen target).
const resolved = comment({ resolvedAt: new Date() as any });
const { queryClient, wrapper } = seededClient(resolved);
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: false })
.catch(() => undefined);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(notifications.show).toHaveBeenCalledWith({
message: "Failed to re-open comment",
color: "red",
});
expect(notifications.show).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Failed to resolve comment" }),
);
// Rolled back: the comment is still present and still resolved.
expect(items(queryClient)).toHaveLength(1);
expect(items(queryClient)[0].resolvedAt).toBeTruthy();
});
it("reopen via Undo FAILS (non-404) → inline mark is NOT left cleared (doc↔panel stay consistent)", async () => {
// First resolve succeeds → produces the Undo toast (no mark change on resolve).
vi.mocked(resolveComment).mockResolvedValueOnce(
comment({ resolvedAt: new Date() as any }),
);
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current.mutateAsync({
commentId: "c-1",
pageId: PAGE_ID,
resolved: true,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Now the reopen fired by Undo fails with a 500.
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
const onClick = undoOnClickFromToast();
onClick();
await waitFor(() => expect(result.current.isError).toBe(true));
// Core F1 guarantee: the mark-clear now lives in the reopen onSuccess, so a
// FAILED reopen must never flip the inline mark to unresolved — otherwise the
// doc would show an active highlight the panel still treats as resolved and
// the collab mark would diverge with nothing committed on the server.
expect(
editorMock.current!.commands.setCommentResolved,
).not.toHaveBeenCalledWith("c-1", false);
// Cache rolled back: the comment stays resolved and present.
expect(items(queryClient)).toHaveLength(1);
expect(items(queryClient)[0].resolvedAt).toBeTruthy();
});
it("reopen success with a null editorRef degrades gracefully (no throw, no-op)", async () => {
// Read-only view / panel closed: pageEditorAtom is null on the success path.
editorMock.current = null;
vi.mocked(resolveComment).mockResolvedValue(comment({ resolvedAt: null }));
const resolved = comment({ resolvedAt: new Date() as any });
const { queryClient, wrapper } = seededClient(resolved);
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current.mutateAsync({
commentId: "c-1",
pageId: PAGE_ID,
resolved: false,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// No crash from the reopen mark-clear; the plain reopen toast is still shown.
expect(notifications.show).toHaveBeenCalledWith({
message: "Comment re-opened successfully",
});
// Cache updated to reopened (resolvedAt cleared by the server payload).
expect(items(queryClient)).toHaveLength(1);
expect(items(queryClient)[0].resolvedAt).toBeFalsy();
});
it("non-404 error on RESOLVE shows 'Failed to resolve comment' and rolls back", async () => {
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useResolveCommentMutation(), {
wrapper,
});
await result.current
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
.catch(() => undefined);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(notifications.show).toHaveBeenCalledWith({
message: "Failed to resolve comment",
color: "red",
});
// Rolled back to open (previousCache), still present.
expect(items(queryClient)).toHaveLength(1);
expect(items(queryClient)[0].resolvedAt).toBeFalsy();
});
});
@@ -20,12 +20,19 @@ import {
ISuggestionOutcome,
} from "@/features/comment/types/comment.types";
import { notifications } from "@mantine/notifications";
import { Button, Group, Text } from "@mantine/core";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo } from "react";
import React, { useEffect, useMemo, useRef } from "react";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
export const RQ_KEY = (pageId: string) => ["comments", pageId];
// How long the resolve success toast (with its inline Undo) stays up before it
// auto-closes. Policy constant — no env override.
export const RESOLVE_UNDO_AUTOCLOSE_MS = 10000;
export function useCommentsQuery(params: ICommentParams) {
const query = useInfiniteQuery({
queryKey: RQ_KEY(params.pageId),
@@ -376,7 +383,25 @@ export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
// Keep the live editor in a ref: the toast's Undo (and the 404 branch) must
// clear the inline comment mark AFTER the originating CommentListItem has
// unmounted (resolving pulls the comment out of the Open list, so its item is
// already gone by the time the 10s toast is clicked). In read-only view
// pageEditorAtom is null and the mark converges via the server's
// COMMENT_MARK_UPDATE job instead.
const editor = useAtomValue(pageEditorAtom);
const editorRef = useRef(editor);
editorRef.current = editor;
// Self-reference the mutation so the toast's Undo can re-invoke it (reopen)
// long after the triggering component unmounted. Declared BEFORE useMutation
// and assigned AFTER; the onClick reads mutationRef.current at CALL time, not
// definition time, so there is no initialization cycle.
const mutationRef = useRef<{
mutate: (vars: IResolveComment) => void;
} | null>(null);
const mutation = useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
@@ -401,7 +426,39 @@ export function useResolveCommentMutation() {
return { previousCache };
},
onError: (_err, variables, context) => {
onError: (err: any, variables, context) => {
// Terminal 404: the comment was really deleted (missing comment or deleted
// page — access denial is 403, resolve is idempotent so no 400). Do NOT
// roll back (that would resurrect a phantom row in Resolved); instead drop
// it from the cache and clear its now-orphaned inline mark. Mirrors
// handleDeleteComment and the dismiss-mutation 404 branch.
if (err?.response?.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),
);
}
const ed = editorRef.current;
if (ed && !ed.isDestroyed) {
try {
ed.commands.unsetComment(variables.commentId);
} catch {
/* editor gone / mark already removed */
}
}
notifications.show({
message: t("Comment no longer exists"),
color: "red",
});
return;
}
// Generic failure: roll back the optimistic update and show a DIRECTIONAL
// error (resolve vs. reopen), not always "resolve".
if (context?.previousCache) {
queryClient.setQueryData(
RQ_KEY(variables.pageId),
@@ -409,7 +466,9 @@ export function useResolveCommentMutation() {
);
}
notifications.show({
message: t("Failed to resolve comment"),
message: variables.resolved
? t("Failed to resolve comment")
: t("Failed to re-open comment"),
color: "red",
});
},
@@ -430,11 +489,72 @@ export function useResolveCommentMutation() {
);
}
// Reopen keeps the plain toast without an Undo.
if (!variables.resolved) {
// Clear the inline mark ONLY after the server confirms the reopen, so a
// failed reopen never leaves an active highlight the panel still treats
// as resolved. Mirrors the 404 branch's editor-liveness guard/try-catch.
// The button-triggered reopen already set the mark, so this is an
// idempotent no-op there.
const ed = editorRef.current;
if (ed && !ed.isDestroyed) {
try {
ed.commands.setCommentResolved(variables.commentId, false);
} catch {
/* editor gone — server COMMENT_MARK_UPDATE converges it */
}
}
notifications.show({ message: t("Comment re-opened successfully") });
return;
}
// Resolve: attach an inline Undo (reopen) to the success toast. Built with
// React.createElement because this is a .ts module (no JSX).
const { commentId, pageId } = variables;
const notificationId = `resolve-undo-${commentId}`;
// Double-click guard: notifications.hide is NOT synchronous, so the button
// stays clickable for a frame or two — without this a fast double-click
// would fire reopen twice.
let done = false;
notifications.show({
message: variables.resolved
? t("Comment resolved successfully")
: t("Comment re-opened successfully"),
id: notificationId,
autoClose: RESOLVE_UNDO_AUTOCLOSE_MS,
message: React.createElement(
Group,
{ justify: "space-between", wrap: "nowrap", gap: "md" },
React.createElement(
Text,
{ size: "sm" },
t("Comment resolved successfully"),
),
React.createElement(
Button,
{
variant: "subtle",
size: "compact-sm",
onClick: () => {
if (done) return;
done = true;
// Reopen via the SAME mutation (read at click time — the
// originating item is already unmounted).
mutationRef.current?.mutate({
commentId,
pageId,
resolved: false,
});
// The inline mark is cleared in the reopen mutation's onSuccess
// (bound to server confirmation), NOT here — clearing it eagerly
// would desync the doc from the panel if reopen then fails.
notifications.hide(notificationId);
},
},
t("Undo"),
),
),
});
},
});
mutationRef.current = mutation;
return mutation;
}
@@ -189,10 +189,11 @@ export function stepBudgetWarning(stepNumber: number): string {
//
// `system` is the in-scope system prompt; we CONCATENATE so the original
// persona/context is preserved — a bare `system` override would REPLACE the
// 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.
// whole system prompt for the step. `activatedTools` is a closure Set grown by
// loadTools and owned by the streaming loop; the caller seeds it from and
// persists it to the chat's metadata across turns (#490), but this function only
// READS the Set it is handed, so it stays a pure function of its arguments (not
// module-global).
//
// 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.
@@ -1410,10 +1411,11 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
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.
// - `activatedTools` is a fresh closure Set per streamText call (not
// module-global), SEEDED from the chat's persisted metadata.activatedTools
// (#490, just below) so activation carries across turns. loadTools.execute
// adds to it; prepareAgentStep reads it to widen `activeTools` on the NEXT
// step; turn end persists it back.
// - `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
@@ -0,0 +1,131 @@
// p-limit and @sindresorhus/slugify are ESM-only and not in jest's transform
// allowlist; both are irrelevant to createDrawioSvg (a pure fs + string method),
// so they are mocked out to keep the module graph loadable under ts-jest.
jest.mock('p-limit', () => ({
__esModule: true,
default: () => (fn: () => unknown) => fn(),
}));
jest.mock('@sindresorhus/slugify', () => ({
__esModule: true,
default: (input: string) => String(input),
}));
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ImportAttachmentService } from './import-attachment.service';
/**
* Unit test for ImportAttachmentService.createDrawioSvg (issue #507).
*
* The Confluence import wraps a `.drawio` file into a `.drawio.svg` attachment.
* The `content=` payload MUST be the mxfile XML entity-escaped (draw.io's native
* form), NOT base64 — draw.io's editor decodes a base64 content= via Latin-1
* atob, mangling every non-ASCII char into mojibake. createDrawioSvg touches no
* injected dependency, so the service is built with placeholder deps.
*/
describe('ImportAttachmentService.createDrawioSvg (#507)', () => {
const service = new ImportAttachmentService(
{} as any,
{} as any,
{} as any,
);
const call = (p: string): Promise<Buffer> =>
(service as any).createDrawioSvg(p);
let tmpDir: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'drawio-507-'));
});
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
const writeDrawio = async (name: string, xml: string): Promise<string> => {
const p = path.join(tmpDir, name);
await fs.writeFile(p, xml, 'utf-8');
return p;
};
it('writes content= as entity-encoded XML, not base64', async () => {
const drawio =
'<mxfile host="Confluence"><diagram name="Схема — ёж">' +
'<mxGraphModel><root><mxCell id="0"/>' +
'<mxCell id="2" value="Старт-бит" vertex="1" parent="0"/>' +
'</root></mxGraphModel></diagram></mxfile>';
const p = await writeDrawio('cyrillic.drawio', drawio);
const svg = (await call(p)).toString('utf-8');
const content = /content="([^"]*)"/.exec(svg)?.[1];
expect(content).toBeDefined();
// Entity-encoded XML form, starting with &lt;mxfile — never a base64 blob.
expect(content).toMatch(/^&lt;mxfile/);
expect(content).toContain('&lt;');
// Non-ASCII survives as raw UTF-8, with no Latin-1 mojibake.
expect(content).toContain('Старт-бит');
expect(content).toContain('Схема — ёж');
expect(content).not.toContain('Ð');
// Decoding the attribute (un-escaping) yields the original drawio file.
const decoded = content!
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&');
expect(decoded).toBe(drawio);
});
it('encodes literal tab/newline/CR as numeric char-refs, not literal control chars (#507 F1)', async () => {
// A literal tab/newline/CR inside the mxfile XML would be collapsed to a
// single space by XML attribute-value normalization when the draw.io editor
// reads content=, silently flattening multi-line labels and tab-bearing
// values. They must be emitted as numeric char-refs instead.
const drawio =
'<mxfile><diagram name="p">' +
'<mxGraphModel><root><mxCell id="0"/>' +
'<mxCell id="2" value="col1\tcol2" style="html=1;\nshadow=0" vertex="1" parent="0"/>' +
'<mxCell id="3" value="Line1\nLine2\rLine3" vertex="1" parent="0"/>' +
'</root></mxGraphModel></diagram></mxfile>';
const p = await writeDrawio('ctrl.drawio', drawio);
const svg = (await call(p)).toString('utf-8');
const content = /content="([^"]*)"/.exec(svg)?.[1];
expect(content).toBeDefined();
// No literal control chars survive in the attribute value.
expect(content).not.toMatch(/[\t\n\r]/);
// They round-trip as numeric char-refs.
expect(content).toContain('&#x9;');
expect(content).toContain('&#xa;');
expect(content).toContain('&#xd;');
// Decoding (char-refs back to literal, entities back) recovers the file.
const decoded = content!
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x9;/gi, '\t')
.replace(/&#xa;/gi, '\n')
.replace(/&#xd;/gi, '\r')
.replace(/&amp;/g, '&');
expect(decoded).toBe(drawio);
});
it('escapes XML metacharacters in the drawio payload', async () => {
const drawio = '<mxfile><diagram name="a &amp; b">"q" &lt;x&gt;</diagram></mxfile>';
const p = await writeDrawio('meta.drawio', drawio);
const svg = (await call(p)).toString('utf-8');
const content = /content="([^"]*)"/.exec(svg)?.[1];
expect(content).toBeDefined();
// The attribute value must contain no bare `<`, `>` or `"` that would break
// out of the content="..." attribute or the SVG element.
expect(content).not.toMatch(/[<>"]/);
const decoded = content!
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&');
expect(decoded).toBe(drawio);
});
});
@@ -8,6 +8,7 @@ import { createReadStream } from 'node:fs';
import { promises as fs } from 'fs';
import { Readable } from 'stream';
import { getMimeType, sanitizeFileName } from '../../../common/helpers';
import { htmlEscape } from '../../../common/helpers/html-escaper';
import { v7 } from 'uuid';
import { FileTask } from '@docmost/db/types/entity.types';
import { getAttachmentFolderPath } from '../../../core/attachment/attachment.utils';
@@ -849,7 +850,12 @@ export class ImportAttachmentService {
): Promise<Buffer> {
try {
const drawioContent = await fs.readFile(drawioPath, 'utf-8');
const drawioBase64 = Buffer.from(drawioContent).toString('base64');
// Write the mxfile XML XML-entity-escaped (draw.io's native content= form),
// NOT base64. draw.io's editor decodes a base64 content= via Latin-1 atob
// (no UTF-8 step), turning every non-ASCII char (Cyrillic, ё, —) into
// mojibake; the entity-encoded form is decoded by the DOM as UTF-8 and
// opens intact. Docmost's own decoder reads both forms.
const drawioEscaped = this.xmlEscapeContent(drawioContent);
let imageElement = '';
// If we have a PNG, include it in the SVG
@@ -875,7 +881,7 @@ export class ImportAttachmentService {
width="600"
height="400"
viewBox="0 0 600 400"
content="${drawioBase64}">${imageElement}</svg>`;
content="${drawioEscaped}">${imageElement}</svg>`;
return Buffer.from(svgContent, 'utf-8');
} catch (error) {
@@ -884,6 +890,24 @@ export class ImportAttachmentService {
}
}
/**
* Escape a string so it is safe as the value of a double-quoted XML attribute
* (the `content=` payload of a `.drawio.svg`). The shared `htmlEscape` covers
* `& < > " '` (a strict superset of what this attribute needs; the extra `'`
* escape is harmless in a `"`-delimited value). On top of that, the numeric
* char-refs for tab/newline/CR are required: a literal tab/newline/CR inside
* an attribute value is collapsed to a single space by XML attribute-value
* normalization on DOM read (both our decoder and the real draw.io editor),
* silently flattening multi-line labels and tab-bearing values. Char-refs
* survive that normalization (#507).
*/
private xmlEscapeContent(s: string): string {
return htmlEscape(s)
.replace(/\t/g, '&#x9;')
.replace(/\n/g, '&#xa;')
.replace(/\r/g, '&#xd;');
}
private async uploadWithRetry(opts: {
abs: string;
storageFilePath: string;
@@ -322,20 +322,21 @@ describe('AiChatService.stream [integration]', () => {
});
/**
* #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.
* #332 + #490 deferred tool loading, the ON path. Turn 1 starts COLD (CORE +
* loadTools only) and activates a deferred tool via loadTools; that activation
* is PERSISTED into the chat's metadata.activatedTools (#490) so the NEXT turn
* SEEDS from it and the tool is active from the fresh turn's FIRST step — the
* model never re-runs loadTools to re-activate the same tool. The unit tests
* only exercise pure prepareAgentStep with hand-fed Sets; this pins the real
* wiring end-to-end (loadTools.execute -> activatedTools -> persist -> next-turn
* seed -> prepareStep -> per-step activeTools) against the real streamText loop.
* 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 AND, seeded from metadata, on the next
* turn's first step.
*/
describe('deferred tool loading ON — per-turn activation, no leak (#332)', () => {
describe('deferred tool loading ON — cross-turn activation persistence (#332 + #490)', () => {
// 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.
@@ -451,7 +452,7 @@ describe('AiChatService.stream [integration]', () => {
} as any);
}
it('activates a deferred tool for the SAME turn, and a NEW turn starts cold (no leak)', async () => {
it('activates a deferred tool for the SAME turn, and a NEW turn SEEDS it from persisted chat metadata (#490)', async () => {
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
// --- Turn 1: loadTools(createPage) on step 1, then answer on step 2. ---
@@ -474,7 +475,7 @@ describe('AiChatService.stream [integration]', () => {
// 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. ---
// --- Turn 2 on the SAME chat: seeds the persisted activation (#490). ---
const model2 = new MockLanguageModelV3({
doStream: async () => ({ stream: successStream() }),
} as any);
@@ -485,9 +486,10 @@ describe('AiChatService.stream [integration]', () => {
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');
// #490: activation PERSISTS across turns — turn 1 wrote createPage into the
// chat's metadata.activatedTools, so the next turn seeds from it and the
// deferred tool is active from the FIRST step (no need to re-run loadTools).
expect(nextTurnFirstStep).toContain('createPage');
});
});
});
+36 -9
View File
@@ -178,10 +178,11 @@ function sliceModel(xml: string): string | null {
// --- decode chain ----------------------------------------------------------
/**
* Read the `content=` attribute out of a `.drawio.svg` string. Docmost stores a
* base64 payload there (createDrawioSvg); draw.io's own SVG export may store the
* XML entity-encoded instead. The DOM decodes entities for us, so the caller
* only has to distinguish "starts with '<'" (raw XML) from base64.
* Read the `content=` attribute out of a `.drawio.svg` string. Docmost writes
* the mxfile XML entity-encoded there (buildDrawioSvg / createDrawioSvg), which
* is also how draw.io's own SVG export stores it; older attachments stored a
* base64 payload instead. The DOM decodes entities for us, so the caller only
* has to distinguish "starts with '<'" (raw XML) from base64.
*/
export function extractContentAttr(svg: string): string {
const { doc, error } = parseXml(svg);
@@ -195,12 +196,23 @@ export function extractContentAttr(svg: string): string {
// value itself never contains a double-quote (base64 / entity-encoded XML).
const m = /content="([^"]*)"/.exec(svg);
if (m) {
// Decode the handful of XML entities a raw regex would leave encoded.
// Decode the handful of XML entities a raw regex would leave encoded. The
// numeric char-refs for tab/newline/CR MUST be decoded here too: the DOM
// path above turns them back into the literal control chars, so this
// regex fallback has to agree or the two decode paths diverge (#507).
// `&amp;` is decoded last so an escaped `&amp;#x9;` reads back as the
// literal text `&#x9;`, not a tab.
return m[1]
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x9;/gi, "\t")
.replace(/&#9;/g, "\t")
.replace(/&#xa;/gi, "\n")
.replace(/&#10;/g, "\n")
.replace(/&#xd;/gi, "\r")
.replace(/&#13;/g, "\r")
.replace(/&amp;/g, "&");
}
throw new Error("drawio: SVG has no content= attribute to decode");
@@ -307,9 +319,16 @@ export function encodeDrawioFile(modelXml: string, title = "Page-1"): string {
/**
* Build the `diagram.drawio.svg` attachment. Mirrors the import service's
* createDrawioSvg contract exactly:
* <svg xmlns=… xmlns:xlink=… content="${base64(drawioFile)}">${inner}</svg>
* <svg xmlns=… xmlns:xlink=… content="${xmlEscape(drawioFile)}">${inner}</svg>
* plus width/height/viewBox from the diagram bounding box and the schematic
* preview as the visible children (`inner`).
*
* The `content=` value is the mxfile XML XML-entity-escaped (draw.io's own
* native form), NOT base64. draw.io's editor decodes a base64 content= via
* Latin-1 atob (no UTF-8 step), turning every non-ASCII char (e.g. Cyrillic,
* ё, —) into mojibake; the entity-encoded form is decoded by the DOM as UTF-8
* and opens intact. Our decoder (decodeDrawioSvg) reads both forms, so old
* base64 attachments still round-trip.
*/
export function buildDrawioSvg(
modelXml: string,
@@ -318,14 +337,14 @@ export function buildDrawioSvg(
title = "Page-1",
): string {
const file = encodeDrawioFile(modelXml, title);
const base64 = Buffer.from(file, "utf-8").toString("base64");
const content = xmlEscape(file);
const w = Math.max(1, Math.round(bbox.width));
const h = Math.max(1, Math.round(bbox.height));
return (
`<svg xmlns="http://www.w3.org/2000/svg" ` +
`xmlns:xlink="http://www.w3.org/1999/xlink" ` +
`width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" ` +
`content="${base64}">${inner}</svg>`
`content="${content}">${inner}</svg>`
);
}
@@ -334,7 +353,15 @@ function xmlEscape(s: string): string {
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
.replace(/"/g, "&quot;")
// A literal tab/newline/CR inside an attribute value is collapsed to a
// single space by XML attribute-value normalization on DOM read (both jsdom
// here and the real draw.io editor), silently flattening multi-line labels
// and tab-bearing values. Numeric char-refs survive that normalization, so
// emit them the way draw.io's own native export does (#507).
.replace(/\t/g, "&#x9;")
.replace(/\n/g, "&#xa;")
.replace(/\r/g, "&#xd;");
}
// --- normalization + hash --------------------------------------------------
+123 -2
View File
@@ -51,6 +51,14 @@ const hasRule = (issues, rule, cellId) =>
(i) => i.rule === rule && (cellId === undefined || i.cellId === cellId),
);
// Reverse the attribute-value XML escaping used in the `content=` payload.
const unescapeAttr = (s) =>
s
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&");
// --- style parsing ---------------------------------------------------------
test("parseStyle: base stylename + key=value pairs", () => {
@@ -335,16 +343,20 @@ test("encode/build: a title with < > \" & round-trips without corrupting the SVG
// The outer content="..." attribute must not be broken by the title: the raw
// title metacharacters never appear literally in the SVG markup (they are
// base64-encoded inside content=, and escaped inside the file XML).
// entity-escaped inside content=, doubly so where the file XML already escaped
// them inside name="...").
const contentMatch = /content="([^"]*)"/.exec(svg);
assert.ok(contentMatch, "SVG has a single well-formed content= attribute");
// The diagram model still decodes losslessly despite the exotic title.
assert.equal(decodeDrawioSvg(svg), model);
// content= is now entity-encoded XML (draw.io's native form), never base64.
assert.match(contentMatch[1], /^&lt;mxfile/);
// The file XML is well-formed: the title lives in name="..." as escaped
// entities, so unescaping recovers the original title byte-for-byte.
const fileXml = Buffer.from(contentMatch[1], "base64").toString("utf-8");
const fileXml = unescapeAttr(contentMatch[1]);
const nameMatch = /<diagram id="[^"]*" name="([^"]*)">/.exec(fileXml);
assert.ok(nameMatch, "the diagram name attribute is intact and quote-safe");
const decodedTitle = nameMatch[1]
@@ -358,3 +370,112 @@ test("encode/build: a title with < > \" & round-trips without corrupting the SVG
const file = encodeDrawioFile(model, title);
assert.match(file, /name="A &lt; B &gt; C &quot; D &amp; E">/);
});
// --- #507: content= is entity-encoded XML, never base64 --------------------
// A model whose cell values carry Cyrillic, ё and an em dash — exactly the
// characters draw.io's Latin-1 atob mangles when content= is base64.
const CYRILLIC_MODEL =
"<mxGraphModel><root>" +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="Старт-бит — ёж" style="rounded=1;html=1;" vertex="1" parent="1">' +
'<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>' +
"</root></mxGraphModel>";
test("#507: buildDrawioSvg writes content= as entity-encoded XML (not base64)", () => {
const model = normalizeXml(CYRILLIC_MODEL);
const svg = buildDrawioSvg(model, "<g/>", { width: 200, height: 120 }, "Диаграмма");
const contentMatch = /content="([^"]*)"/.exec(svg);
assert.ok(contentMatch, "SVG has a single well-formed content= attribute");
const content = contentMatch[1];
// The content= value is the entity-encoded mxfile XML — starts with `&lt;mxfile`.
assert.match(content, /^&lt;mxfile/, "content= is entity-encoded mxfile XML");
// It must NOT be a base64 blob: base64 has no XML entities and no literal `<`.
assert.ok(content.includes("&lt;"), "content= carries XML entities, not base64");
// Cyrillic / ё / — survive verbatim in the attribute (raw UTF-8, not atob-mangled).
assert.ok(content.includes("Старт-бит — ёж"), "non-ASCII value is raw UTF-8 in content=");
assert.ok(content.includes("Диаграмма"), "non-ASCII title is raw UTF-8 in content=");
// The mojibake that base64+atob would have produced must be absent.
assert.ok(!content.includes("Ð"), "no Latin-1 mojibake in content=");
});
test("#507: Cyrillic model round-trips byte-stable through buildDrawioSvg -> decodeDrawioSvg", () => {
const model = normalizeXml(CYRILLIC_MODEL);
const svg = buildDrawioSvg(model, "<g/>", { width: 200, height: 120 }, "Заголовок — ё");
assert.equal(decodeDrawioSvg(svg), model);
});
test("#507 back-compat: an OLD base64-form .drawio.svg still decodes losslessly", () => {
const model = normalizeXml(CYRILLIC_MODEL);
// Reproduce the pre-fix write path: encodeDrawioFile -> base64 in content=.
const file = encodeDrawioFile(model, "Старая диаграмма");
const base64 = Buffer.from(file, "utf-8").toString("base64");
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" content="${base64}"><g/></svg>`;
// No XML entities, purely base64 alphabet — this is the legacy form.
assert.ok(!base64.includes("<") && !base64.includes("&"));
assert.equal(decodeDrawioSvg(svg), model);
});
test("#507 negative: non-ASCII in a cell value AND in the title round-trip clean", () => {
const model = normalizeXml(CYRILLIC_MODEL);
const title = "Тест — ёмкость № 5";
const svg = buildDrawioSvg(model, "<g/>", { width: 200, height: 120 }, title);
// Model recovered byte-for-byte.
assert.equal(decodeDrawioSvg(svg), model);
// Title lands in the <diagram name="..."> of the decoded file XML, intact.
const content = /content="([^"]*)"/.exec(svg)[1];
const fileXml = unescapeAttr(content);
const nameMatch = /<diagram id="[^"]*" name="([^"]*)">/.exec(fileXml);
assert.ok(nameMatch, "diagram name attribute present");
assert.equal(unescapeAttr(nameMatch[1]), title);
});
// --- #507 F1: literal tab/newline/CR in an attribute value survive the
// content= round-trip. The whole mxfile XML lives in one content="..." attr;
// XML attribute-value normalization collapses a LITERAL tab/newline/CR to a
// single space on DOM read, so the escape must emit numeric char-refs instead.
const CTRL_MODEL =
"<mxGraphModel><root>" +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="col1\tcol2" style="html=1;\nshadow=0" vertex="1" parent="1">' +
'<mxGeometry x="10" y="10" width="120" height="60" as="geometry"/></mxCell>' +
'<mxCell id="3" value="Line1\nLine2\rLine3" vertex="1" parent="1">' +
'<mxGeometry x="10" y="100" width="120" height="60" as="geometry"/></mxCell>' +
"</root></mxGraphModel>";
test("#507 F1: literal tab/newline/CR in a value round-trip byte-stable (DOM decode)", () => {
const svg = buildDrawioSvg(CTRL_MODEL, "<g/>", { width: 200, height: 200 });
const content = /content="([^"]*)"/.exec(svg)[1];
// The tab/newline/CR must be emitted as numeric char-refs, never as literal
// control chars (which DOM attribute-value normalization would eat).
assert.ok(
!/[\t\n\r]/.test(content),
"no literal tab/newline/CR survive in the content= attribute",
);
assert.ok(
content.includes("&#x9;") &&
content.includes("&#xa;") &&
content.includes("&#xd;"),
"tab/newline/CR are emitted as numeric char-refs",
);
// Full DOM decode recovers the model byte-for-byte, control chars intact.
assert.equal(decodeDrawioSvg(svg), CTRL_MODEL);
});
test("#507 F1: regex fallback decodes tab/newline/CR char-refs (agrees with DOM path)", () => {
const svg = buildDrawioSvg(CTRL_MODEL, "<g/>", { width: 200, height: 200 });
const content = /content="([^"]*)"/.exec(svg)[1];
// A bare `&` makes the SVG wrapper malformed, forcing extractContentAttr onto
// its regex fallback branch. That branch must decode the tab/newline/CR
// char-refs exactly like the DOM path, or the two decoders diverge.
const malformedSvg = `<svg content="${content}">&</svg>`;
assert.equal(decodeDrawioSvg(malformedSvg), CTRL_MODEL);
// The well-formed (DOM) path yields the identical result.
assert.equal(decodeDrawioSvg(svg), CTRL_MODEL);
});