Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3cc6a7ff8 | |||
| c42cb42413 | |||
| 41030b2c78 | |||
| 03eafa6c68 | |||
| a42f1ead48 | |||
| b7a3ec227d | |||
| 846341d7d4 | |||
| 32e10ca6d3 | |||
| 64566e9327 | |||
| 10a4326fbf | |||
| 68409a8ae9 | |||
| fc624f5a4b |
@@ -463,6 +463,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `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. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||
- 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`.
|
||||
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -142,6 +142,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
snapshots switched from a fixed interval to a trailing idle-flush with a
|
||||
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
|
||||
changes (e.g. a person's edits followed by the AI agent). (#370)
|
||||
- **Open tabs pick up a new deploy on their own.** After the server is
|
||||
redeployed while a tab is left open for hours, the tab now learns the new
|
||||
build version over the existing WebSocket (announced per-connect, so a natural
|
||||
reconnect delivers it) and shows a "A new version is available" banner with an
|
||||
Update button. To avoid dropping a half-written comment or form, the tab is
|
||||
not reloaded when you merely switch away from it; instead it auto-reloads at
|
||||
the next safe point — the next in-app navigation (or immediately if you click
|
||||
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
|
||||
reload happens per 5-minute window, shared with the existing chunk-load
|
||||
recovery, so a permanent version skew degrades to the banner rather than a
|
||||
reload loop while a second deploy in the same tab still recovers. When the
|
||||
build carries no version info the feature stays inert. (#481)
|
||||
|
||||
- **Place several images side by side in a row.** A new "Inline (side by
|
||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||
@@ -392,6 +404,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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"A new version is available": "A new version is available",
|
||||
"Account": "Account",
|
||||
"Active": "Active",
|
||||
"Add": "Add",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"A new version is available": "Доступна новая версия",
|
||||
"Account": "Аккаунт",
|
||||
"Active": "Активный",
|
||||
"Add": "Добавить",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isChunkLoadError, shouldAutoReload } from "./chunk-load-error-boundary";
|
||||
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
||||
|
||||
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
||||
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
||||
@@ -35,31 +35,3 @@ describe("isChunkLoadError", () => {
|
||||
expect(isChunkLoadError(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The window gate replaces the old one-shot flag: it must permit recovery across
|
||||
// several deploys in one tab (each > window apart) while still stopping an infinite
|
||||
// reload loop when a lazy chunk is permanently broken (a second failure < window).
|
||||
describe("shouldAutoReload", () => {
|
||||
const WINDOW = 5 * 60 * 1000;
|
||||
const NOW = 1_000_000_000_000;
|
||||
|
||||
it("allows a reload when we have never auto-reloaded", () => {
|
||||
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
||||
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||
|
||||
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload.
|
||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
||||
// by a single reload, so anything inside the window is treated as a reload loop
|
||||
// (permanently-broken chunk) and falls through to the manual UI. A window (rather
|
||||
// than a one-shot flag) lets a SECOND deploy in the same tab's lifetime recover too.
|
||||
const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
// Pure window decision, unit-tested in isolation: auto-reload only if we have never
|
||||
// auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older than the
|
||||
// window. Anything inside the window is suppressed to break an infinite reload loop.
|
||||
export function shouldAutoReload(
|
||||
now: number,
|
||||
lastReloadAt: number | null,
|
||||
windowMs: number,
|
||||
): boolean {
|
||||
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
||||
return now - lastReloadAt > windowMs;
|
||||
}
|
||||
import {
|
||||
hasAutoReloaded,
|
||||
markAutoReloaded,
|
||||
recordReloadBreadcrumb,
|
||||
} from "@/lib/reload-guard";
|
||||
|
||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||
@@ -39,24 +24,26 @@ export function isChunkLoadError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function handleError(error: unknown) {
|
||||
// Exported for tests: the reactive chunk-load reload decision, so the shared
|
||||
// window budget (invariant: ≤1 auto-reload per window across this path AND the
|
||||
// proactive version-coherence path) can be exercised against the real guard.
|
||||
export function handleError(error: unknown) {
|
||||
if (!isChunkLoadError(error)) return;
|
||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||
// the new chunk manifest. Auto-reload at most once per RELOAD_WINDOW_MS: this
|
||||
// recovers across multiple deploys in a single tab's lifetime, yet a
|
||||
// permanently-broken lazy chunk (which would loop) is stopped after the first
|
||||
// reload and falls through to the manual recovery UI below.
|
||||
try {
|
||||
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
||||
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
||||
const now = Date.now();
|
||||
if (!shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS)) return;
|
||||
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||
return;
|
||||
}
|
||||
// the new chunk manifest. Auto-reload at most once per window via the SHARED
|
||||
// window-based reload guard (see @/lib/reload-guard — the same budget the
|
||||
// proactive version-coherence path consumes, so a mismatch that arrives on
|
||||
// both paths reloads at most once per window across BOTH). This recovers
|
||||
// across multiple deploys in a single tab's lifetime, yet a permanently-broken
|
||||
// lazy chunk (which would loop) is stopped after the first reload and falls
|
||||
// through to the manual recovery UI below. If the shared budget is already
|
||||
// spent this window, or the stamp write fails (storage unavailable), we return
|
||||
// without reloading rather than risk a loop.
|
||||
if (hasAutoReloaded()) return;
|
||||
if (!markAutoReloaded()) return;
|
||||
// Trace before the reload clears the console (same diagnostic breadcrumb the
|
||||
// proactive version-coherence path writes, tagged with this path).
|
||||
recordReloadBreadcrumb({ path: "chunk-boundary" });
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, act, cleanup } from "@testing-library/react";
|
||||
import { MemoryRouter, useNavigate } from "react-router-dom";
|
||||
|
||||
// Mocks for the dirty shell's side-effecting collaborators.
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
||||
vi.mock("@/lib/reload-guard", () => ({
|
||||
hasAutoReloaded: vi.fn(() => false),
|
||||
markAutoReloaded: vi.fn(() => true),
|
||||
recordReloadBreadcrumb: vi.fn(),
|
||||
takeReloadBreadcrumb: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
|
||||
import {
|
||||
triggerGuardedReload,
|
||||
useVersionReloadOnNavigation,
|
||||
__resetGuardedReloadForTests,
|
||||
} from "./guarded-reload";
|
||||
|
||||
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
let visibility: DocumentVisibilityState;
|
||||
|
||||
// Test harness mounted inside a router: it installs the navigation hook and
|
||||
// exposes `navigate` so a test can drive an in-app router navigation.
|
||||
let doNavigate: (to: string) => void;
|
||||
function Harness() {
|
||||
useVersionReloadOnNavigation();
|
||||
const navigate = useNavigate();
|
||||
doNavigate = navigate;
|
||||
return null;
|
||||
}
|
||||
|
||||
function mountHarness() {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/start"]}>
|
||||
<Harness />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
act(() => {
|
||||
doNavigate(path);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetGuardedReloadForTests();
|
||||
vi.clearAllMocks();
|
||||
mockHasAutoReloaded.mockReturnValue(false);
|
||||
mockMarkAutoReloaded.mockReturnValue(true);
|
||||
|
||||
vi.stubGlobal("APP_VERSION", "test-A");
|
||||
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { reload },
|
||||
});
|
||||
|
||||
visibility = "visible";
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => visibility,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("triggerGuardedReload (variant C)", () => {
|
||||
it("noop when versions match: no banner, no reload", () => {
|
||||
triggerGuardedReload("test-A");
|
||||
expect(show).not.toHaveBeenCalled();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("noop when the server version is empty (fail-safe)", () => {
|
||||
triggerGuardedReload("");
|
||||
triggerGuardedReload(undefined);
|
||||
expect(show).not.toHaveBeenCalled();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("real mismatch shows the banner but does NOT reload immediately", () => {
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
expect(show.mock.calls[0][0]).toMatchObject({
|
||||
id: "app-version-reload",
|
||||
autoClose: false,
|
||||
withCloseButton: true,
|
||||
});
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A second navigation must NOT reload again (one-shot was consumed).
|
||||
navigateTo("/again");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Another app-version mismatch arrives (reconnect): must not re-arm.
|
||||
triggerGuardedReload("test-C");
|
||||
navigateTo("/again");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT reload merely from the tab going to the background", () => {
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
visibility = "hidden";
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
|
||||
visibility = "hidden";
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("the banner's Update button reloads immediately", () => {
|
||||
triggerGuardedReload("test-B");
|
||||
const message = show.mock.calls[0][0].message as {
|
||||
props: { onClick: () => void };
|
||||
};
|
||||
message.props.onClick();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
|
||||
mockHasAutoReloaded.mockReturnValue(true);
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
|
||||
navigateTo("/next");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT reload when the flag write fails; falls back to the banner", () => {
|
||||
mockMarkAutoReloaded.mockReturnValue(false);
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
// performAutoReload falls back to showing the banner (initial + fallback).
|
||||
expect(show).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
|
||||
triggerGuardedReload("test-B");
|
||||
triggerGuardedReload("test-B");
|
||||
triggerGuardedReload("test-C");
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Button } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import i18n from "@/i18n.ts";
|
||||
import {
|
||||
hasAutoReloaded,
|
||||
markAutoReloaded,
|
||||
recordReloadBreadcrumb,
|
||||
takeReloadBreadcrumb,
|
||||
} from "@/lib/reload-guard";
|
||||
import { decideVersionAction } from "@/features/user/version-coherence";
|
||||
|
||||
// Dirty shell around the pure `decideVersionAction`: it reads globals
|
||||
// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
|
||||
// Mantine notification, and arms the router-navigation reload hook. Kept
|
||||
// separate from the pure module so the decision stays unit-testable without a
|
||||
// DOM.
|
||||
|
||||
// One fixed id so repeated app-version signals (e.g. every reconnect) update a
|
||||
// single banner instead of stacking a new one each time.
|
||||
const BANNER_ID = "app-version-reload";
|
||||
|
||||
// Module-level idempotency for the current tab-load: once a mismatch has been
|
||||
// handled we don't re-arm the navigation reload or re-show the banner on
|
||||
// subsequent app-version emits.
|
||||
let handled = false;
|
||||
|
||||
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
|
||||
// the background (that would silently drop a half-written comment/form). Instead
|
||||
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
|
||||
// the user is already leaving the current page, so an in-app navigation would
|
||||
// discard that unsaved component-state anyway and the reload adds no extra loss.
|
||||
let pendingNavReload = false;
|
||||
|
||||
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
|
||||
// the (already-visible) banner.
|
||||
let lastServerVersion = "";
|
||||
let lastClientVersion = "";
|
||||
|
||||
// Read the build version baked into THIS bundle. The `typeof` guard avoids a
|
||||
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
|
||||
// where Vite's `define` did not run) — an unknown client version makes the
|
||||
// pure decision no-op (fail-safe).
|
||||
function readClientVersion(): string {
|
||||
return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim();
|
||||
}
|
||||
|
||||
// Perform the actual reload — but only after the shared one-shot flag is
|
||||
// persisted. If the write fails (storage unavailable) we must NOT reload
|
||||
// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back
|
||||
// to the manual banner so the user can still recover.
|
||||
function performAutoReload(): void {
|
||||
if (!markAutoReloaded()) {
|
||||
showReloadBanner();
|
||||
return;
|
||||
}
|
||||
// Trace right before the reload (which clears the console): a persistent
|
||||
// breadcrumb + a log line so the auto-reload is observable in a field report.
|
||||
recordReloadBreadcrumb({
|
||||
path: "proactive",
|
||||
serverVersion: lastServerVersion,
|
||||
clientVersion: lastClientVersion,
|
||||
});
|
||||
console.warn(
|
||||
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
|
||||
);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function showReloadBanner(): void {
|
||||
notifications.show({
|
||||
id: BANNER_ID,
|
||||
title: i18n.t("A new version is available"),
|
||||
message: (
|
||||
<Button size="xs" mt="xs" onClick={() => performAutoReload()}>
|
||||
{i18n.t("Update")}
|
||||
</Button>
|
||||
),
|
||||
autoClose: false,
|
||||
withCloseButton: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a server `app-version` announcement: compare it to this bundle's
|
||||
* version and, on a real mismatch, show the banner and arm a guarded reload for
|
||||
* the next in-app navigation (variant C).
|
||||
*
|
||||
* - real mismatch (window budget available) → banner + arm navigation reload.
|
||||
* The banner's "Update" button reloads immediately (same shared window guard).
|
||||
* The tab is NOT reloaded on visibility change.
|
||||
* - auto-reload already used this window / storage error → banner only (no arm),
|
||||
* so there is at most one automatic reload per RELOAD_WINDOW_MS window (loop
|
||||
* safety).
|
||||
* - in sync / unknown version → noop (fail-safe).
|
||||
*/
|
||||
export function triggerGuardedReload(
|
||||
rawServerVersion: string | undefined | null,
|
||||
): void {
|
||||
const serverVersion = (rawServerVersion ?? "").trim();
|
||||
const clientVersion = readClientVersion();
|
||||
|
||||
// A storage read error surfaces as autoReloadUsed=true → fail toward NOT
|
||||
// reloading (banner only).
|
||||
const autoReloadUsed = hasAutoReloaded();
|
||||
|
||||
const action = decideVersionAction({
|
||||
serverVersion,
|
||||
clientVersion,
|
||||
autoReloadUsed,
|
||||
});
|
||||
if (action === "noop") return;
|
||||
|
||||
// Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
|
||||
// emits (reconnects) once we've already acted.
|
||||
if (handled) return;
|
||||
handled = true;
|
||||
|
||||
lastServerVersion = serverVersion;
|
||||
lastClientVersion = clientVersion;
|
||||
|
||||
if (action === "banner") {
|
||||
// Entered banner-only (permanent skew, node oscillation, or the window's
|
||||
// auto-reload budget already spent). Log for diagnosability; show the banner.
|
||||
console.warn(
|
||||
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
|
||||
"auto-reload budget already spent this window — showing manual banner",
|
||||
);
|
||||
showReloadBanner();
|
||||
return;
|
||||
}
|
||||
|
||||
// action === "reload" (variant C): show the banner and defer the auto-reload
|
||||
// to the next in-app navigation instead of reloading now / on visibility.
|
||||
showReloadBanner();
|
||||
pendingNavReload = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the armed one-shot navigation reload, if any. Called by
|
||||
* `useVersionReloadOnNavigation` on each in-app router navigation.
|
||||
*/
|
||||
export function consumeNavigationReload(): void {
|
||||
if (!pendingNavReload) return;
|
||||
pendingNavReload = false;
|
||||
performAutoReload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
|
||||
* NEXT in-app router navigation after a version mismatch. Skips the initial
|
||||
* render so it only reacts to real navigations, not the first location.
|
||||
*/
|
||||
export function useVersionReloadOnNavigation(): void {
|
||||
const location = useLocation();
|
||||
const firstRender = useRef(true);
|
||||
useEffect(() => {
|
||||
if (firstRender.current) {
|
||||
firstRender.current = false;
|
||||
return;
|
||||
}
|
||||
consumeNavigationReload();
|
||||
}, [location.key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
|
||||
* load — the reload cleared the console, so this makes a "tab reloaded itself"
|
||||
* report diagnosable. Call once on app startup.
|
||||
*/
|
||||
export function surfacePreviousReloadBreadcrumb(): void {
|
||||
const crumb = takeReloadBreadcrumb();
|
||||
if (!crumb) return;
|
||||
console.info(
|
||||
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
|
||||
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
|
||||
`at=${new Date(crumb.at).toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Test-only: reset module-level latches between cases.
|
||||
export function __resetGuardedReloadForTests(): void {
|
||||
handled = false;
|
||||
pendingNavReload = false;
|
||||
lastServerVersion = "";
|
||||
lastClientVersion = "";
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, act, cleanup } from "@testing-library/react";
|
||||
import { MemoryRouter, useNavigate } from "react-router-dom";
|
||||
|
||||
// Integration test for the SHARED, window-based auto-reload budget (invariant a):
|
||||
// the reactive chunk-load boundary and the proactive version-coherence path both
|
||||
// route through the REAL @/lib/reload-guard, so at most one automatic reload
|
||||
// happens per RELOAD_WINDOW_MS across BOTH paths combined. Only the two paths'
|
||||
// side-effecting collaborators are mocked — the reload guard is intentionally
|
||||
// REAL so this exercises the actual shared sessionStorage budget.
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
||||
|
||||
import { handleError } from "@/components/chunk-load-error-boundary";
|
||||
import {
|
||||
triggerGuardedReload,
|
||||
useVersionReloadOnNavigation,
|
||||
__resetGuardedReloadForTests,
|
||||
} from "./guarded-reload";
|
||||
import { RELOAD_WINDOW_MS } from "@/lib/reload-guard";
|
||||
|
||||
const CHUNK_ERROR = { name: "ChunkLoadError", message: "boom" };
|
||||
const T0 = 1_000_000_000_000;
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
let nowMock: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
// Harness mounted inside a router: installs the navigation hook and exposes
|
||||
// `navigate` so a test can drive an in-app router navigation (the point where the
|
||||
// proactive path fires its armed reload).
|
||||
let doNavigate: (to: string) => void;
|
||||
function Harness() {
|
||||
useVersionReloadOnNavigation();
|
||||
doNavigate = useNavigate();
|
||||
return null;
|
||||
}
|
||||
function mountHarness() {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/start"]}>
|
||||
<Harness />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
function navigateTo(path: string) {
|
||||
act(() => {
|
||||
doNavigate(path);
|
||||
});
|
||||
}
|
||||
function setNow(t: number) {
|
||||
nowMock.mockReturnValue(t);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
__resetGuardedReloadForTests();
|
||||
vi.clearAllMocks();
|
||||
nowMock = vi.spyOn(Date, "now").mockReturnValue(T0);
|
||||
vi.stubGlobal("APP_VERSION", "test-A");
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { reload },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("shared window-based reload budget (invariant a)", () => {
|
||||
it("a chunk-load reload spends the budget: a version-coherence mismatch within the window shows the banner but does NOT reload", () => {
|
||||
// Path 1 (reactive): a stale-chunk 404 auto-reloads once and stamps the window.
|
||||
handleError(CHUNK_ERROR);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
reload.mockClear();
|
||||
|
||||
// Path 2 (proactive), 1 min later — still inside the window. The shared budget
|
||||
// is spent, so the version mismatch degrades to the banner and never reloads.
|
||||
setNow(T0 + 60_000);
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a version-coherence reload spends the SAME budget: a chunk-load error within the window does NOT reload", () => {
|
||||
// Path 2 (proactive) first: real mismatch → arm → fire on navigation.
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
reload.mockClear();
|
||||
|
||||
// Path 1 (reactive), 2 min later — inside the window. Budget already spent by
|
||||
// the proactive path, so the stale-chunk error must NOT trigger a second reload.
|
||||
setNow(T0 + 2 * 60_000);
|
||||
handleError(CHUNK_ERROR);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers after the window: a reload strictly older than the window is allowed again (second deploy)", () => {
|
||||
// First auto-reload (reactive) stamps the window.
|
||||
handleError(CHUNK_ERROR);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
reload.mockClear();
|
||||
|
||||
// A second deploy arrives after the window has fully elapsed → the proactive
|
||||
// path is allowed to reload again (window, not a permanent one-shot).
|
||||
__resetGuardedReloadForTests();
|
||||
setNow(T0 + RELOAD_WINDOW_MS + 1);
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reactive path fails closed when storage READS but cannot WRITE (quota / Safari private): no unguarded reload", () => {
|
||||
// getItem→null makes hasAutoReloaded() report the budget as available, so
|
||||
// handleError passes the first guard and reaches `if (!markAutoReloaded())
|
||||
// return;`. setItem throws → the stamp cannot stick, so markAutoReloaded()
|
||||
// returns false and that guard MUST bail — otherwise the reactive path would
|
||||
// reload on every stale-chunk error with no persisted budget (an unguarded
|
||||
// loop). This is the asymmetric gap: the proactive path's equivalent is
|
||||
// covered by guarded-reload.test.tsx "does NOT reload when the flag write
|
||||
// fails".
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("quota exceeded");
|
||||
},
|
||||
removeItem: () => {},
|
||||
clear: () => {},
|
||||
});
|
||||
try {
|
||||
handleError(CHUNK_ERROR);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("sessionStorage unavailable: neither path performs an unguarded reload", () => {
|
||||
// The real guard fails toward NOT reloading when storage throws.
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
removeItem: () => {},
|
||||
clear: () => {},
|
||||
});
|
||||
try {
|
||||
handleError(CHUNK_ERROR);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,12 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||
import {
|
||||
triggerGuardedReload,
|
||||
useVersionReloadOnNavigation,
|
||||
surfacePreviousReloadBreadcrumb,
|
||||
} from "@/features/user/guarded-reload.tsx";
|
||||
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
|
||||
|
||||
export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||
@@ -22,6 +28,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
// fetch collab token on load
|
||||
const { data: collab } = useCollabToken();
|
||||
|
||||
// version-coherence: fire the armed one-shot reload on the next in-app
|
||||
// navigation (variant C — a safe point, not on tab backgrounding).
|
||||
useVersionReloadOnNavigation();
|
||||
|
||||
// Surface any breadcrumb left by an auto-reload in the previous page load
|
||||
// (the reload cleared the console) so a field report stays diagnosable.
|
||||
useEffect(() => {
|
||||
surfacePreviousReloadBreadcrumb();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isError) {
|
||||
return;
|
||||
@@ -47,6 +63,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
handleConnect();
|
||||
});
|
||||
|
||||
// Register the version-coherence listener SYNCHRONOUSLY, before the socket
|
||||
// connects: the server emits `app-version` immediately in handleConnection,
|
||||
// so a listener attached after connect would miss it on a fast localhost
|
||||
// connect. On a version mismatch the client shows a banner and defers the
|
||||
// auto-reload to the next in-app navigation (variant C — avoids reloading a
|
||||
// backgrounded tab that may hold unsaved input) before it hits a stale chunk.
|
||||
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
|
||||
triggerGuardedReload(payload?.version);
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log("ws disconnected");
|
||||
newSocket.disconnect();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideVersionAction } from "./version-coherence";
|
||||
|
||||
describe("decideVersionAction", () => {
|
||||
it("noop when the server version is empty (fail-safe)", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "",
|
||||
clientVersion: "v1",
|
||||
autoReloadUsed: false,
|
||||
}),
|
||||
).toBe("noop");
|
||||
});
|
||||
|
||||
it("noop when the client version is empty (fail-safe)", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "v1",
|
||||
clientVersion: "",
|
||||
autoReloadUsed: false,
|
||||
}),
|
||||
).toBe("noop");
|
||||
});
|
||||
|
||||
it("noop when versions are equal (in sync)", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "v1",
|
||||
clientVersion: "v1",
|
||||
autoReloadUsed: false,
|
||||
}),
|
||||
).toBe("noop");
|
||||
});
|
||||
|
||||
it("reload on a real mismatch the first time this session", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "test-B",
|
||||
clientVersion: "test-A",
|
||||
autoReloadUsed: false,
|
||||
}),
|
||||
).toBe("reload");
|
||||
});
|
||||
|
||||
it("banner on a mismatch once the session auto-reload is spent", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "test-B",
|
||||
clientVersion: "test-A",
|
||||
autoReloadUsed: true,
|
||||
}),
|
||||
).toBe("banner");
|
||||
});
|
||||
|
||||
it("equal versions stay noop even if auto-reload was already used", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "v1",
|
||||
clientVersion: "v1",
|
||||
autoReloadUsed: true,
|
||||
}),
|
||||
).toBe("noop");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Payload of the per-connect `app-version` socket.io event announced by the
|
||||
// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a
|
||||
// member of the room-scoped `WebSocketEvent` union (which is discriminated by
|
||||
// `operation`), so it never touches use-query-subscription.
|
||||
export type AppVersionSocketPayload = { version: string };
|
||||
|
||||
/**
|
||||
* Pure decision for the version-coherence guard.
|
||||
*
|
||||
* All inputs are injected (no globals, no side effects) so it is unit-testable
|
||||
* without a DOM or the build-time `APP_VERSION` global (undefined under vitest).
|
||||
*
|
||||
* - `autoReloadUsed` = an automatic reload has already happened within the
|
||||
* current ~5-min window, so we must not auto-reload again (loop safety,
|
||||
* shared window budget with the reactive chunk-load boundary).
|
||||
*
|
||||
* Returns:
|
||||
* - "noop" — do nothing (unknown version on either side, or already in sync).
|
||||
* - "banner" — show the manual "update available" banner only (no auto-reload).
|
||||
* - "reload" — real first-time mismatch: eligible for a guarded auto-reload.
|
||||
*/
|
||||
export function decideVersionAction(args: {
|
||||
serverVersion: string;
|
||||
clientVersion: string;
|
||||
autoReloadUsed: boolean;
|
||||
}): "reload" | "banner" | "noop" {
|
||||
const { serverVersion, clientVersion, autoReloadUsed } = args;
|
||||
if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
|
||||
if (serverVersion === clientVersion) return "noop"; // in sync
|
||||
if (autoReloadUsed) return "banner"; // one auto-reload per RELOAD_WINDOW_MS window already spent
|
||||
return "reload"; // real mismatch, window budget available
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
hasAutoReloaded,
|
||||
markAutoReloaded,
|
||||
shouldAutoReload,
|
||||
recordReloadBreadcrumb,
|
||||
takeReloadBreadcrumb,
|
||||
RELOAD_WINDOW_MS,
|
||||
} from "./reload-guard";
|
||||
|
||||
// The shared budget is a single sessionStorage timestamp keyed here; both the
|
||||
// reactive chunk-load boundary and the proactive version-coherence path read and
|
||||
// stamp it, so at most one auto-reload happens per RELOAD_WINDOW_MS across BOTH.
|
||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||
const NOW = 1_000_000_000_000;
|
||||
|
||||
describe("reload-guard", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("hasAutoReloaded is false before any reload, true within the window after mark", () => {
|
||||
expect(hasAutoReloaded(NOW)).toBe(false);
|
||||
expect(markAutoReloaded(NOW)).toBe(true);
|
||||
// Same key both paths share; stores the reload timestamp, not a flag.
|
||||
expect(sessionStorage.getItem(RELOAD_AT_KEY)).toBe(String(NOW));
|
||||
// Inside the window → budget spent → true (fall through to manual UI).
|
||||
expect(hasAutoReloaded(NOW)).toBe(true);
|
||||
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS)).toBe(true);
|
||||
});
|
||||
|
||||
it("hasAutoReloaded is false again once the window has elapsed (a later deploy recovers)", () => {
|
||||
markAutoReloaded(NOW);
|
||||
// Strictly older than the window → a new deploy's mismatch may reload again.
|
||||
expect(hasAutoReloaded(NOW + RELOAD_WINDOW_MS + 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => {
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(hasAutoReloaded()).toBe(true);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("hasAutoReloaded treats an unparseable stored timestamp as never-reloaded", () => {
|
||||
sessionStorage.setItem(RELOAD_AT_KEY, "not-a-number");
|
||||
expect(hasAutoReloaded(NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("markAutoReloaded returns false when writing storage throws", () => {
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(markAutoReloaded()).toBe(false);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("records and then takes a breadcrumb once (cleared on read)", () => {
|
||||
recordReloadBreadcrumb({
|
||||
path: "proactive",
|
||||
serverVersion: "test-B",
|
||||
clientVersion: "test-A",
|
||||
});
|
||||
const crumb = takeReloadBreadcrumb();
|
||||
expect(crumb).toMatchObject({
|
||||
path: "proactive",
|
||||
serverVersion: "test-B",
|
||||
clientVersion: "test-A",
|
||||
});
|
||||
expect(typeof crumb?.at).toBe("number");
|
||||
// Cleared on read → a second take returns null.
|
||||
expect(takeReloadBreadcrumb()).toBeNull();
|
||||
});
|
||||
|
||||
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
|
||||
expect(takeReloadBreadcrumb()).toBeNull();
|
||||
});
|
||||
|
||||
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
removeItem: () => {},
|
||||
});
|
||||
try {
|
||||
expect(() =>
|
||||
recordReloadBreadcrumb({ path: "chunk-boundary" }),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The pure window gate replaces the old one-shot flag: it must permit recovery
|
||||
// across several deploys in one tab (each > window apart) while still stopping an
|
||||
// infinite reload loop when a lazy chunk is permanently broken (a second failure
|
||||
// < window). Moved here from the chunk-load boundary now that it is the shared
|
||||
// guard both paths route through.
|
||||
describe("shouldAutoReload", () => {
|
||||
const WINDOW = RELOAD_WINDOW_MS;
|
||||
|
||||
it("allows a reload when we have never auto-reloaded", () => {
|
||||
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
||||
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
||||
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// Shared, window-based auto-reload budget.
|
||||
//
|
||||
// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers
|
||||
// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature
|
||||
// (reloads BEFORE the tab hits a stale chunk) — go through these functions so
|
||||
// they share ONE window-scoped reload budget: at most a single automatic
|
||||
// reload per RELOAD_WINDOW_MS across BOTH paths. A window (rather than a
|
||||
// permanent one-shot flag) lets a SECOND deploy in the same tab's lifetime
|
||||
// recover too, while a permanent skew, node oscillation, or a genuinely-missing
|
||||
// chunk still degrades to a manual banner/UI after the first reload instead of
|
||||
// looping. When sessionStorage is unavailable every mismatch degrades to the
|
||||
// manual UI — no unguarded reload.
|
||||
|
||||
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload
|
||||
// (shared by both paths).
|
||||
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||
|
||||
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
||||
// by a single reload, so anything inside the window is treated as a reload loop
|
||||
// (permanently-broken chunk / permanent skew) and falls through to the manual UI.
|
||||
export const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Pure window decision, unit-tested in isolation: auto-reload only if we have
|
||||
* never auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older
|
||||
* than the window. Anything inside the window is suppressed to break an infinite
|
||||
* reload loop.
|
||||
*/
|
||||
export function shouldAutoReload(
|
||||
now: number,
|
||||
lastReloadAt: number | null,
|
||||
windowMs: number,
|
||||
): boolean {
|
||||
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
||||
return now - lastReloadAt > windowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has an automatic reload already happened within the current window (so the
|
||||
* shared budget is spent right now)? Both paths check this before reloading; a
|
||||
* `true` return means fall through to the manual banner/UI instead of reloading.
|
||||
*
|
||||
* A storage read error (private mode / disabled) is reported as `true` so the
|
||||
* caller fails toward NOT reloading — an unguarded loop is worse than a stale
|
||||
* tab the user can reload manually. Note a window (not a permanent flag): once
|
||||
* the window elapses a later deploy's mismatch is allowed to reload again.
|
||||
*/
|
||||
export function hasAutoReloaded(now: number = Date.now()): boolean {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
||||
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
||||
return !shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the shared window as consumed now — record that an automatic reload is
|
||||
* being performed within the current RELOAD_WINDOW_MS window.
|
||||
*
|
||||
* Returns whether the write succeeded. A `false` return (storage unavailable)
|
||||
* means the caller MUST NOT reload — otherwise the stamp would never stick and
|
||||
* the reload could loop.
|
||||
*/
|
||||
export function markAutoReloaded(now: number = Date.now()): boolean {
|
||||
try {
|
||||
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic breadcrumb for an automatic reload. Written right before
|
||||
// window.location.reload() (which clears the console) and read back on the next
|
||||
// page load, so a "the tab reloaded itself / it's looping" field report is
|
||||
// diagnosable: which path fired (proactive version-coherence vs the reactive
|
||||
// chunk-load boundary) and which version pair triggered it. sessionStorage
|
||||
// survives a same-tab reload, unlike the console.
|
||||
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
|
||||
|
||||
export type ReloadBreadcrumb = {
|
||||
path: "proactive" | "chunk-boundary";
|
||||
serverVersion?: string;
|
||||
clientVersion?: string;
|
||||
at: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist a best-effort breadcrumb just before an automatic reload. Failures
|
||||
* (storage unavailable) are swallowed — this is diagnostics only and must never
|
||||
* block or alter the reload decision.
|
||||
*/
|
||||
export function recordReloadBreadcrumb(
|
||||
entry: Omit<ReloadBreadcrumb, "at">,
|
||||
): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
RELOAD_BREADCRUMB_KEY,
|
||||
JSON.stringify({ ...entry, at: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
// best-effort diagnostics only
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and clear the breadcrumb left by an auto-reload in the previous page
|
||||
* load. Cleared on read so it surfaces exactly once per reload.
|
||||
*/
|
||||
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
|
||||
if (!raw) return null;
|
||||
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
|
||||
return JSON.parse(raw) as ReloadBreadcrumb;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { compression } from "vite-plugin-compression2";
|
||||
import * as path from "path";
|
||||
import * as fs from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const envPath = path.resolve(process.cwd(), "..", "..");
|
||||
@@ -24,7 +25,32 @@ function resolveAppVersion(cwd: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
|
||||
// the exact same build id the bundle was compiled with. The value is the SAME
|
||||
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
|
||||
// global are identical by construction — the single source of truth (no
|
||||
// runtime-env second copy that could drift and cause a false version mismatch).
|
||||
function versionJsonPlugin(version: string): Plugin {
|
||||
let outDir = "dist";
|
||||
return {
|
||||
name: "emit-version-json",
|
||||
apply: "build",
|
||||
configResolved(config) {
|
||||
outDir = config.build.outDir;
|
||||
},
|
||||
writeBundle() {
|
||||
const root = path.resolve(process.cwd(), outDir);
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "version.json"),
|
||||
JSON.stringify({ version }),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const appVersion = resolveAppVersion(envPath);
|
||||
const {
|
||||
APP_URL,
|
||||
FILE_UPLOAD_SIZE_LIMIT,
|
||||
@@ -52,10 +78,11 @@ export default defineConfig(({ mode }) => {
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
},
|
||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||
APP_VERSION: JSON.stringify(appVersion),
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
versionJsonPlugin(appVersion),
|
||||
// Emit .br and .gz next to every built asset so the server can serve the
|
||||
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
||||
compression({
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { join } from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { readClientBuildVersion } from './client-version';
|
||||
|
||||
describe('readClientBuildVersion', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const writeVersionJson = (content: string) =>
|
||||
fs.writeFileSync(join(dir, 'version.json'), content);
|
||||
|
||||
it('returns the version from a valid version.json', () => {
|
||||
writeVersionJson(JSON.stringify({ version: 'test-A' }));
|
||||
expect(readClientBuildVersion(dir)).toBe('test-A');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace in the version', () => {
|
||||
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
|
||||
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
|
||||
});
|
||||
|
||||
it('returns "" when version.json is missing', () => {
|
||||
expect(readClientBuildVersion(dir)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" on malformed JSON', () => {
|
||||
writeVersionJson('{ not json');
|
||||
expect(readClientBuildVersion(dir)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" when the version field is absent', () => {
|
||||
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
|
||||
expect(readClientBuildVersion(dir)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" when the version field is not a string', () => {
|
||||
writeVersionJson(JSON.stringify({ version: 123 }));
|
||||
expect(readClientBuildVersion(dir)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" when the path does not exist at all', () => {
|
||||
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { join } from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Resolve the absolute path to the built client bundle directory
|
||||
* (`apps/client/dist`) shipped into the runtime image.
|
||||
*
|
||||
* The `../` depth is anchored on THIS module's compiled location
|
||||
* (`dist/common/helpers`). `integrations/static` sits at the same depth under
|
||||
* the compiled root, so both callers (StaticModule and readClientBuildVersion)
|
||||
* MUST share this single helper rather than duplicating the depth — a copy in a
|
||||
* module at a different depth would silently resolve to the wrong directory.
|
||||
*/
|
||||
export function resolveClientDistPath(): string {
|
||||
return join(__dirname, '..', '..', '..', '..', 'client/dist');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the build version the client bundle was compiled with, from
|
||||
* `<clientDistPath>/version.json` (written by the Vite build — the single
|
||||
* source of truth shared by the baked-in `APP_VERSION` global and this file).
|
||||
*
|
||||
* Fail-safe: any error (missing file, unreadable, bad JSON, non-string
|
||||
* version) yields `''`. The caller treats an empty version as "unknown" and
|
||||
* the whole version-coherence feature stays silently inert — existing deploys
|
||||
* without the file keep working unchanged.
|
||||
*/
|
||||
export function readClientBuildVersion(clientDistPath: string): string {
|
||||
try {
|
||||
const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8');
|
||||
const version = (JSON.parse(raw) as { version?: unknown }).version;
|
||||
return typeof version === 'string' ? version.trim() : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * from './nanoid.utils';
|
||||
export * from './file.helper';
|
||||
export * from './constants';
|
||||
export * from './security-headers';
|
||||
export * from './client-version';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { INTERNAL_LINK_REGEX } from './utils';
|
||||
import { isInternalPagePath } from '@docmost/prosemirror-markdown';
|
||||
|
||||
/**
|
||||
* Cross-package DRIFT GUARD for the internal-link subset invariant (#522).
|
||||
*
|
||||
* The client-side `isInternalPagePath`
|
||||
* (`packages/prosemirror-markdown/src/lib/internal-links.ts`) promotes a markdown
|
||||
* link to `internal: true` on import. Every link it marks internal MUST be one
|
||||
* the server would backlink and export-rewrite — i.e. the client matcher MUST be
|
||||
* a STRICT SUBSET of the server's canonical `INTERNAL_LINK_REGEX`
|
||||
* (`./utils.ts`). If the client ever accepts a path the server rejects, that link
|
||||
* is stored internal but silently dropped from the backlink graph and broken on
|
||||
* export — the exact bug #522 fixed.
|
||||
*
|
||||
* This spec is the load-bearing guard: it imports the LIVE server regex AND the
|
||||
* LIVE `isInternalPagePath` (no hand-copied regex on either side). A narrowing of
|
||||
* EITHER — most dangerously the server regex — reddens here. The in-package
|
||||
* accept/reject test documents the client's behaviour but cannot see the server
|
||||
* regex; this top-layer spec is what makes the subset relation mechanical
|
||||
* (AGENTS.md rule #7: a CI test that fails on drift of the source of truth).
|
||||
*/
|
||||
describe('internal-link subset parity (client isInternalPagePath ⊆ server INTERNAL_LINK_REGEX)', () => {
|
||||
// Every path the CLIENT accepts. Kept deliberately broad across the risky
|
||||
// dimensions — the slug charset (digits, hyphens, mixed case, leading/trailing
|
||||
// hyphen), the space charset, and the optional trailing slash — so a narrowing
|
||||
// of the server regex on any of them reddens the subset assertion below.
|
||||
const CLIENT_ACCEPTS = [
|
||||
'/s/eng/p/abc123',
|
||||
'/s/eng/p/abc123/', // trailing slash
|
||||
'/s/My-Space/p/A-b-C-9', // hyphens + mixed case in both segments
|
||||
'/s/x/p/z', // shortest
|
||||
'/s/eng/p/my-page-abc123', // slug with hyphens (extractPageSlugId shape)
|
||||
'/s/eng/p/-lead', // leading hyphen in slug
|
||||
'/s/eng/p/trail-', // trailing hyphen in slug
|
||||
'/s/eng/p/0123456789', // all-digit slug
|
||||
'/s/a.b/p/abc', // dot in the SPACE segment (space charset is [^/]+)
|
||||
'/s/space with space/p/abc', // space char in the SPACE segment
|
||||
];
|
||||
|
||||
it('every corpus path is actually client-accepted (guards the corpus itself)', () => {
|
||||
// If a path here stopped being client-accepted the subset test would pass
|
||||
// vacuously; assert acceptance up front so the corpus stays meaningful. The
|
||||
// filter-to-empty form names the offending paths on failure.
|
||||
const notAccepted = CLIENT_ACCEPTS.filter((h) => !isInternalPagePath(h));
|
||||
expect(notAccepted).toEqual([]);
|
||||
});
|
||||
|
||||
it('every client-accepted path also matches the LIVE server regex (subset)', () => {
|
||||
// The mechanical drift guard: narrow the server INTERNAL_LINK_REGEX and at
|
||||
// least one hyphen/charset/structure case appears here.
|
||||
const notInServer = CLIENT_ACCEPTS.filter((h) => !INTERNAL_LINK_REGEX.test(h));
|
||||
expect(notInServer).toEqual([]);
|
||||
});
|
||||
|
||||
it('the client rejects forbidden-slug-char paths the server also rejects', () => {
|
||||
// Documents the subset BOUNDARY: correct shape, forbidden slug char. The
|
||||
// client and the LIVE server regex must agree on rejection.
|
||||
const forbidden = [
|
||||
'/s/eng/p/abc.def',
|
||||
'/s/eng/p/abc_def',
|
||||
'/s/eng/p/abc%20',
|
||||
'/s/eng/p/abc~x',
|
||||
];
|
||||
const clientAccepts = forbidden.filter((h) => isInternalPagePath(h));
|
||||
const serverAccepts = forbidden.filter((h) => INTERNAL_LINK_REGEX.test(h));
|
||||
expect(clientAccepts).toEqual([]);
|
||||
expect(serverAccepts).toEqual([]);
|
||||
});
|
||||
});
|
||||
+131
@@ -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 <mxfile — never a base64 blob.
|
||||
expect(content).toMatch(/^<mxfile/);
|
||||
expect(content).toContain('<');
|
||||
// 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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/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('	');
|
||||
expect(content).toContain('
');
|
||||
expect(content).toContain('
');
|
||||
// Decoding (char-refs back to literal, entities back) recovers the file.
|
||||
const decoded = content!
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/	/gi, '\t')
|
||||
.replace(/
/gi, '\n')
|
||||
.replace(/
/gi, '\r')
|
||||
.replace(/&/g, '&');
|
||||
expect(decoded).toBe(drawio);
|
||||
});
|
||||
|
||||
it('escapes XML metacharacters in the drawio payload', async () => {
|
||||
const drawio = '<mxfile><diagram name="a & b">"q" <x></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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/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, '	')
|
||||
.replace(/\n/g, '
')
|
||||
.replace(/\r/g, '
');
|
||||
}
|
||||
|
||||
private async uploadWithRetry(opts: {
|
||||
abs: string;
|
||||
storageFilePath: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { resolveClientDistPath } from '../../common/helpers/client-version';
|
||||
|
||||
/**
|
||||
* Resolve the response headers for a statically served client asset.
|
||||
@@ -56,14 +57,7 @@ export class StaticModule implements OnModuleInit {
|
||||
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
||||
const app = httpAdapter.getInstance();
|
||||
|
||||
const clientDistPath = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'client/dist',
|
||||
);
|
||||
const clientDistPath = resolveClientDistPath();
|
||||
|
||||
const indexFilePath = join(clientDistPath, 'index.html');
|
||||
|
||||
|
||||
@@ -9,10 +9,14 @@ import {
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { TokenService } from '../core/auth/services/token.service';
|
||||
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
||||
import { OnModuleDestroy } from '@nestjs/common';
|
||||
import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { WsService } from './ws.service';
|
||||
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
|
||||
import {
|
||||
readClientBuildVersion,
|
||||
resolveClientDistPath,
|
||||
} from '../common/helpers/client-version';
|
||||
import * as cookie from 'cookie';
|
||||
|
||||
@WebSocketGateway({
|
||||
@@ -20,17 +24,40 @@ import * as cookie from 'cookie';
|
||||
transports: ['websocket'],
|
||||
})
|
||||
export class WsGateway
|
||||
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
|
||||
implements
|
||||
OnGatewayConnection,
|
||||
OnGatewayInit,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy
|
||||
{
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
private readonly logger = new Logger(WsGateway.name);
|
||||
|
||||
// The build version of the client bundle shipped in this image, read once at
|
||||
// startup from client/dist/version.json (single source of truth, same value
|
||||
// baked into the client's APP_VERSION). Empty string => version.json missing
|
||||
// or empty => the proactive version-coherence reload feature stays inert.
|
||||
private appVersion = '';
|
||||
|
||||
constructor(
|
||||
private tokenService: TokenService,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
private wsService: WsService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.appVersion = readClientBuildVersion(resolveClientDistPath());
|
||||
if (this.appVersion) {
|
||||
this.logger.log(`app-version reload: ACTIVE (v=${this.appVersion})`);
|
||||
} else {
|
||||
this.logger.log(
|
||||
'app-version reload: DISABLED (version.json missing/empty)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
afterInit(server: Server): void {
|
||||
this.wsService.setServer(server);
|
||||
}
|
||||
@@ -55,6 +82,14 @@ export class WsGateway
|
||||
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
|
||||
|
||||
client.join([userRoom, workspaceRoom, ...spaceRooms]);
|
||||
|
||||
// Announce this container's client build version to the freshly
|
||||
// authenticated socket. On a redeploy the client reconnects to the new
|
||||
// container and receives the new version here, letting it guard-reload
|
||||
// before it hits a stale lazy chunk. Per-connect only (no broadcast):
|
||||
// natural reconnect covers both single-container and cluster without a
|
||||
// thundering-herd fleet reload.
|
||||
client.emit('app-version', { version: this.appVersion });
|
||||
} catch (err) {
|
||||
client.emit('Unauthorized');
|
||||
client.disconnect();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
// `&` is decoded last so an escaped `&#x9;` reads back as the
|
||||
// literal text `	`, not a tab.
|
||||
return m[1]
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/	/gi, "\t")
|
||||
.replace(/	/g, "\t")
|
||||
.replace(/
/gi, "\n")
|
||||
.replace(/ /g, "\n")
|
||||
.replace(/
/gi, "\r")
|
||||
.replace(/ /g, "\r")
|
||||
.replace(/&/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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
.replace(/"/g, """)
|
||||
// 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, "	")
|
||||
.replace(/\n/g, "
")
|
||||
.replace(/\r/g, "
");
|
||||
}
|
||||
|
||||
// --- normalization + hash --------------------------------------------------
|
||||
|
||||
@@ -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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/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], /^<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 < B > C " D & 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 `<mxfile`.
|
||||
assert.match(content, /^<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("<"), "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("	") &&
|
||||
content.includes("
") &&
|
||||
content.includes("
"),
|
||||
"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);
|
||||
});
|
||||
|
||||
@@ -25,22 +25,13 @@
|
||||
* drop any attr whose value equals its known schema default. A non-default
|
||||
* value (e.g. `orderedList.start: 5`) is NOT a default, so it is KEPT.
|
||||
*
|
||||
* Every entry below — with the single documented exception of the `link.internal`
|
||||
* marker (see its own bullet) — was read from `packages/docmost-client/src/lib/
|
||||
* Every entry below was read from `packages/docmost-client/src/lib/
|
||||
* docmost-schema.ts` (the line refs are the exact `default:` declarations) and
|
||||
* confirmed to be materialized by an export→import→export round-trip:
|
||||
* - mark `link` target / rel — DocmostAttributes + StarterKit link.
|
||||
* StarterKit's link extension defaults `target: "_blank"` and
|
||||
* `rel: "noopener noreferrer nofollow"`; both materialize on import
|
||||
* (empirically confirmed) even when the source had only `href`.
|
||||
* - mark `link` internal (#522) — the ONE editor-sourced, NOT-import-
|
||||
* materialized default here. Its source is `editor-ext/src/lib/link.ts`
|
||||
* (default `internal: false`), not docmost-schema, and import does NOT
|
||||
* materialize it (an imported external link leaves `internal` absent/null,
|
||||
* never `false`). It is listed so that the editor's stored `internal:false`
|
||||
* normalizes to the same "external" canon as absent/null (`false ≡ absent`),
|
||||
* keeping a stored external link canonically equal to its re-import. The
|
||||
* load-bearing `internal:true` is NON-default and therefore KEPT.
|
||||
* - mark `comment` resolved — docmost-schema.ts L213-214 (`default: false`).
|
||||
* - node `orderedList` start — provided by StarterKit's orderedList
|
||||
* (`default: 1`); materializes on import (empirically confirmed).
|
||||
@@ -65,14 +56,6 @@ const KNOWN_DEFAULTS: Record<string, Record<string, unknown>> = {
|
||||
link: {
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer nofollow",
|
||||
// Editor-authored EXTERNAL links store `internal: false` (editor-ext link
|
||||
// default `packages/editor-ext/src/lib/link.ts`), while an imported external
|
||||
// link leaves `internal` absent/null. Both mean "external", so `internal:
|
||||
// false` must normalize away exactly like `null`/absent — otherwise a stored
|
||||
// `internal:false` link diverges from its re-import under
|
||||
// `docsCanonicallyEqual` (false !== null). The internal marker `internal:true`
|
||||
// is NON-default, so it is KEPT and survives canonicalization (#522 §11).
|
||||
internal: false,
|
||||
},
|
||||
comment: {
|
||||
resolved: false,
|
||||
|
||||
@@ -38,10 +38,6 @@ export {
|
||||
normalizeForeignMarkdown,
|
||||
normalizeAgentMarkdown,
|
||||
} from "./foreign-markdown.js";
|
||||
// Pure primitive: detect the unambiguously-internal wiki-page link path
|
||||
// (`/s/<space>/p/<slug>`) and promote such link marks to their native internal
|
||||
// form during import (#522). A strict subset of the server's INTERNAL_LINK_REGEX.
|
||||
export { isInternalPagePath, markInternalLinks } from "./internal-links.js";
|
||||
|
||||
// The Docmost tiptap schema mirror. Exposed so consumers (and the sync
|
||||
// engine's schema-validity regression tests) can build the exact ProseMirror
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Detect and mark unambiguously-internal wiki-page links during markdown import.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* ---------------
|
||||
* The markdown converter (`markdownToProseMirrorSync`) materializes every link
|
||||
* with StarterKit's external defaults — `internal: null`, `target: "_blank"`,
|
||||
* `rel: "noopener noreferrer nofollow"`. A markdown link that points at an
|
||||
* internal wiki page written in its host-less, root-relative form
|
||||
* (`[text](/s/<space>/p/<slugId>)`) is therefore stored as EXTERNAL: it opens in
|
||||
* a new tab, gets no hover-preview, and is invisible to the backlink graph
|
||||
* (`extractInternalLinkSlugIds` only counts links whose mark carries
|
||||
* `internal: true`). This module supplies the pure primitive that a post-walk in
|
||||
* the converter uses to promote such links to their native internal form.
|
||||
*
|
||||
* WHERE THE CANON LIVES (a strict subset, on purpose)
|
||||
* ---------------------------------------------------
|
||||
* The authoritative definition of "what is an internal link path" is the
|
||||
* server's `INTERNAL_LINK_REGEX`
|
||||
* (`apps/server/src/integrations/export/utils.ts`):
|
||||
* /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/
|
||||
* This package is a lower layer than the server app and cannot import it, so the
|
||||
* matcher below is a DOCUMENTED, STRICT SUBSET of that regex: it accepts only the
|
||||
* space-qualified, root-relative page path `/s/<space>/p/<slug>` (with optional
|
||||
* trailing slash). Because it is a subset, every link we mark internal here is
|
||||
* guaranteed to also satisfy the server regex, hence guaranteed backlink-able and
|
||||
* export-rewritable. A unit test pins the exact accept/reject set as the guard
|
||||
* against drift.
|
||||
*
|
||||
* FAIL-TOWARD-EXTERNAL
|
||||
* --------------------
|
||||
* We mark a link internal ONLY on an unambiguous anchored match. Any ambiguity
|
||||
* (a scheme/host, `#anchor`, `?query`, a space-less `/p/<slug>`, a relative
|
||||
* `p/<slug>`, a non-string href) leaves the link external. A false-external is a
|
||||
* soft degradation (a new tab); a false-internal would produce broken SPA
|
||||
* navigation, so "external" is the conservative default.
|
||||
*
|
||||
* Precedent for the target internal shape: the file importer already promotes
|
||||
* internal anchors (`apps/server/src/integrations/import/utils/import-formatter.ts`
|
||||
* `$a.attr('data-internal','true')`) and editor-ext reads it
|
||||
* (`packages/editor-ext/src/lib/link.ts`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Matches ONLY the space-qualified, root-relative internal page path
|
||||
* `/s/<space>/p/<slug>` (with optional trailing slash). A STRICT SUBSET of the
|
||||
* server's `INTERNAL_LINK_REGEX`:
|
||||
* - `<space>` = `[^/]+` (any non-slash segment, as in the server's group 4)
|
||||
* - `<slug>` = `[a-zA-Z0-9-]+` (as in the server's group 5 / `extractPageSlugId`)
|
||||
* Anchored on both ends so a scheme/host, a trailing `#anchor`/`?query`, or any
|
||||
* extra path segment fails to match.
|
||||
*/
|
||||
const INTERNAL_PAGE_PATH = /^\/s\/[^/]+\/p\/[a-zA-Z0-9-]+\/?$/;
|
||||
|
||||
/**
|
||||
* True iff `href` is the unambiguous, space-qualified, root-relative internal
|
||||
* page path. Pure and side-effect-free. Non-string input returns false.
|
||||
*/
|
||||
export function isInternalPagePath(href: unknown): boolean {
|
||||
return typeof href === "string" && INTERNAL_PAGE_PATH.test(href);
|
||||
}
|
||||
|
||||
/**
|
||||
* The native internal-link attribute overrides applied to a link mark whose href
|
||||
* is an internal page path. Mirrors the manual JSON-patch form
|
||||
* (`{internal:true, target:null, rel:null}`) and the editor-ext/file-importer
|
||||
* precedent: internal links carry no `target`/`rel` (same-tab SPA navigation).
|
||||
*/
|
||||
const INTERNAL_LINK_ATTRS = { internal: true, target: null, rel: null } as const;
|
||||
|
||||
/**
|
||||
* In-place post-walk of a finished ProseMirror doc that promotes every
|
||||
* unambiguously-internal link mark to its native internal form.
|
||||
*
|
||||
* A link that spans several text nodes (e.g. `[**bold** word](/s/x/p/abc)`)
|
||||
* stores an equivalent link mark on EACH covered text node — and a covered node
|
||||
* may carry nested marks (bold/italic) alongside the link. We therefore walk the
|
||||
* entire tree and rewrite EVERY `link` mark whose href passes
|
||||
* `isInternalPagePath`, so no covered segment is left external.
|
||||
*
|
||||
* Pure of external effects and IDEMPOTENT: re-running on an already-marked doc
|
||||
* leaves it unchanged (an internal-path href always maps to the same attrs).
|
||||
* External links are never touched — their `target:_blank`/`rel:noopener…` stay.
|
||||
*
|
||||
* The doc is mutated in place (the converter owns the freshly-built doc and
|
||||
* returns it directly); the same node reference is returned for convenience.
|
||||
*/
|
||||
export function markInternalLinks<T>(node: T): T {
|
||||
walk(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function walk(node: any): void {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) walk(child);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node.marks)) {
|
||||
for (const mark of node.marks) {
|
||||
if (
|
||||
mark &&
|
||||
mark.type === "link" &&
|
||||
mark.attrs &&
|
||||
isInternalPagePath(mark.attrs.href)
|
||||
) {
|
||||
mark.attrs = { ...mark.attrs, ...INTERNAL_LINK_ATTRS };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) walk(node.content);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { parseHtmlDocument, generateJsonWith } from "./dom-parser.js";
|
||||
import type { TokenizerExtension, RendererExtension } from "marked";
|
||||
import { docmostExtensions } from "./docmost-schema.js";
|
||||
import { parseAttachedComment } from "./attached-comment.js";
|
||||
import { markInternalLinks } from "./internal-links.js";
|
||||
import { splitFootnoteParagraphs } from "./footnote.js";
|
||||
import {
|
||||
decodeInlineMathLatex,
|
||||
@@ -1095,12 +1094,7 @@ export function markdownToProseMirrorSync(markdownContent: string): any {
|
||||
const withFootnotes = assembleFootnotes(withAttrs);
|
||||
const bridged = bridgeTaskLists(withFootnotes);
|
||||
const doc = generateJsonWith(bridged, docmostExtensions);
|
||||
// Promote unambiguously-internal wiki-page links (`[t](/s/<space>/p/<slug>)`)
|
||||
// to their native internal form (`internal:true, target:null, rel:null`) so
|
||||
// they get same-tab SPA navigation, hover-preview, and backlink participation.
|
||||
// Every markdown import path funnels through here, so all of them are fixed at
|
||||
// once; external links are left untouched (#522).
|
||||
return markInternalLinks(stripEmptyParagraphs(doc));
|
||||
return stripEmptyParagraphs(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isInternalPagePath,
|
||||
markInternalLinks,
|
||||
} from '../src/lib/internal-links.js';
|
||||
import { markdownToProseMirrorSync } from '../src/lib/markdown-to-prosemirror.js';
|
||||
import {
|
||||
canonicalizeContent,
|
||||
docsCanonicallyEqual,
|
||||
} from '../src/lib/canonicalize.js';
|
||||
|
||||
// A LOCAL, illustrative copy of the server's INTERNAL_LINK_REGEX
|
||||
// (apps/server/src/integrations/export/utils.ts) used only to document the
|
||||
// subset boundary WITHIN this package (this layer cannot import the server).
|
||||
// It is NOT the cross-package drift guard: a hand copy can silently go stale if
|
||||
// the server regex is later narrowed. The real, mechanical drift guard lives in
|
||||
// the top layer, `apps/server/src/integrations/export/internal-link-parity.spec.ts`,
|
||||
// which imports BOTH the LIVE server `INTERNAL_LINK_REGEX` and the LIVE
|
||||
// `isInternalPagePath` and reddens if the client ever ceases to be a subset.
|
||||
const SERVER_INTERNAL_LINK_REGEX =
|
||||
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
||||
|
||||
// Walk a doc collecting every link mark (across all text nodes).
|
||||
const linkMarks = (doc: any): any[] => {
|
||||
const out: any[] = [];
|
||||
const walk = (n: any): void => {
|
||||
if (!n || typeof n !== 'object') return;
|
||||
if (Array.isArray(n)) return n.forEach(walk);
|
||||
if (Array.isArray(n.marks))
|
||||
for (const m of n.marks) if (m?.type === 'link') out.push(m);
|
||||
if (Array.isArray(n.content)) walk(n.content);
|
||||
};
|
||||
walk(doc);
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('isInternalPagePath', () => {
|
||||
const ACCEPT = [
|
||||
'/s/eng/p/abc123',
|
||||
'/s/eng/p/abc123/', // trailing slash
|
||||
'/s/My-Space/p/A-b-C-9', // hyphens + mixed case in both segments
|
||||
'/s/x/p/z',
|
||||
];
|
||||
const REJECT = [
|
||||
'https://example.com/p/x', // scheme + host
|
||||
'http://host/s/eng/p/abc', // scheme + host, even on the internal shape
|
||||
'//host/s/eng/p/abc', // protocol-relative host
|
||||
'/p/abc123', // space-less form (server does NOT backlink it)
|
||||
'p/abc123', // relative
|
||||
's/eng/p/abc123', // missing leading slash
|
||||
'/s/eng/p/abc#section', // anchor
|
||||
'/s/eng/p/abc?q=1', // query
|
||||
'/s/eng/p/abc/extra', // extra segment
|
||||
'/s//p/abc', // empty space segment
|
||||
'/s/eng/p/', // empty slug
|
||||
'/s/eng/p', // no slug at all
|
||||
'/api/pages/abc', // other internal route
|
||||
'/s/eng/x/abc', // wrong middle segment
|
||||
'/s/a/b/p/abc', // space contains slash -> extra segment
|
||||
'',
|
||||
];
|
||||
// Structurally VALID `/s/<space>/p/<slug>` shapes whose ONLY defect is a
|
||||
// forbidden character in the slug segment. These pin the slug charset
|
||||
// `[a-zA-Z0-9-]` itself (not just the surrounding structure): drop them and a
|
||||
// mutation widening the charset (e.g. adding `.`) survives the suite. Each is
|
||||
// ALSO rejected by the server regex — documenting that the subset boundary
|
||||
// holds precisely on the charset, not just on structure.
|
||||
const REJECT_SLUG_CHARSET = [
|
||||
'/s/eng/p/abc.def', // dot
|
||||
'/s/eng/p/abc_def', // underscore
|
||||
'/s/eng/p/abc%20', // percent-encoded space
|
||||
'/s/eng/p/abc~x', // tilde
|
||||
];
|
||||
|
||||
it('accepts the space-qualified root-relative page path (+trailing slash)', () => {
|
||||
for (const href of ACCEPT)
|
||||
expect(isInternalPagePath(href), href).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects external URLs, ambiguous, and non-page forms', () => {
|
||||
for (const href of REJECT)
|
||||
expect(isInternalPagePath(href), href).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a correctly-shaped path with a forbidden slug character', () => {
|
||||
// Pins the slug charset itself: a mutation widening `[a-zA-Z0-9-]` reddens.
|
||||
for (const href of REJECT_SLUG_CHARSET)
|
||||
expect(isInternalPagePath(href), href).toBe(false);
|
||||
});
|
||||
|
||||
it('the forbidden-slug-char paths are rejected by the server regex too', () => {
|
||||
// The subset boundary holds on the charset, not just the structure: none of
|
||||
// these match the server regex, so the client must not accept them either.
|
||||
for (const href of REJECT_SLUG_CHARSET)
|
||||
expect(SERVER_INTERNAL_LINK_REGEX.test(href), href).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-string input (fail-toward-external)', () => {
|
||||
for (const v of [null, undefined, 123, {}, [], true])
|
||||
expect(isInternalPagePath(v as unknown)).toBe(false);
|
||||
});
|
||||
|
||||
it('is a STRICT SUBSET of the server INTERNAL_LINK_REGEX', () => {
|
||||
// Every accepted href must also satisfy the server regex (so it is
|
||||
// guaranteed backlink-able / export-rewritable).
|
||||
for (const href of ACCEPT)
|
||||
expect(SERVER_INTERNAL_LINK_REGEX.test(href), href).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markInternalLinks', () => {
|
||||
const linkNode = (text: string, href: string, extraMarks: any[] = []) => ({
|
||||
type: 'text',
|
||||
text,
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href,
|
||||
internal: null,
|
||||
title: null,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
class: null,
|
||||
},
|
||||
},
|
||||
...extraMarks,
|
||||
],
|
||||
});
|
||||
|
||||
it('marks an internal-path link internal:true, target:null, rel:null', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [linkNode('hi', '/s/eng/p/abc123')] },
|
||||
],
|
||||
};
|
||||
markInternalLinks(doc);
|
||||
const m = linkMarks(doc)[0];
|
||||
expect(m.attrs.internal).toBe(true);
|
||||
expect(m.attrs.target).toBeNull();
|
||||
expect(m.attrs.rel).toBeNull();
|
||||
expect(m.attrs.href).toBe('/s/eng/p/abc123'); // href untouched
|
||||
});
|
||||
|
||||
it('leaves external links untouched', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [linkNode('ext', 'https://example.com/p/x')],
|
||||
},
|
||||
],
|
||||
};
|
||||
markInternalLinks(doc);
|
||||
const m = linkMarks(doc)[0];
|
||||
expect(m.attrs.internal).toBeNull();
|
||||
expect(m.attrs.target).toBe('_blank');
|
||||
expect(m.attrs.rel).toBe('noopener noreferrer nofollow');
|
||||
});
|
||||
|
||||
it('marks EVERY text node a multi-node link spans (incl. nested bold/italic)', () => {
|
||||
// A link `[**bold** plain *ital*](/s/x/p/abc)` stores the link mark on each
|
||||
// covered text node; some also carry bold/italic. All must become internal.
|
||||
const bold = { type: 'bold' };
|
||||
const italic = { type: 'italic' };
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
linkNode('bold', '/s/x/p/abc', [bold]),
|
||||
linkNode(' plain ', '/s/x/p/abc'),
|
||||
linkNode('ital', '/s/x/p/abc', [italic]),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
markInternalLinks(doc);
|
||||
const marks = linkMarks(doc);
|
||||
expect(marks).toHaveLength(3);
|
||||
for (const m of marks) {
|
||||
expect(m.attrs.internal).toBe(true);
|
||||
expect(m.attrs.target).toBeNull();
|
||||
expect(m.attrs.rel).toBeNull();
|
||||
}
|
||||
// Nested bold/italic marks are preserved alongside the promoted link.
|
||||
const nested = doc.content[0].content;
|
||||
expect(nested[0].marks.some((m: any) => m.type === 'bold')).toBe(true);
|
||||
expect(nested[2].marks.some((m: any) => m.type === 'italic')).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [linkNode('hi', '/s/eng/p/abc')] },
|
||||
],
|
||||
};
|
||||
markInternalLinks(doc);
|
||||
const once = JSON.stringify(doc);
|
||||
markInternalLinks(doc);
|
||||
expect(JSON.stringify(doc)).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown import (full converter) — #522 acceptance', () => {
|
||||
it('promotes an internal link and leaves an external one external', () => {
|
||||
const doc = markdownToProseMirrorSync(
|
||||
'[t](/s/eng/p/abc123) and [ext](https://example.com/p/x)',
|
||||
);
|
||||
const marks = linkMarks(doc);
|
||||
const internal = marks.find((m) => m.attrs.href === '/s/eng/p/abc123');
|
||||
const external = marks.find(
|
||||
(m) => m.attrs.href === 'https://example.com/p/x',
|
||||
);
|
||||
expect(internal.attrs).toMatchObject({
|
||||
internal: true,
|
||||
target: null,
|
||||
rel: null,
|
||||
});
|
||||
expect(external.attrs).toMatchObject({
|
||||
internal: null,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT promote the space-less /p/<slug> form (documented limit)', () => {
|
||||
const doc = markdownToProseMirrorSync('[t](/p/abc123)');
|
||||
const m = linkMarks(doc)[0];
|
||||
expect(m.attrs.internal).toBeNull();
|
||||
expect(m.attrs.target).toBe('_blank');
|
||||
});
|
||||
|
||||
it('the promoted link is backlink-extractable (matches server regex + true)', () => {
|
||||
// Mirrors extractInternalLinkSlugIds: internal flag AND server-regex match.
|
||||
const doc = markdownToProseMirrorSync('[t](/s/eng/p/my-page-abc123)');
|
||||
const m = linkMarks(doc)[0];
|
||||
expect(m.attrs.internal).toBe(true);
|
||||
const match = m.attrs.href.match(SERVER_INTERNAL_LINK_REGEX);
|
||||
expect(match).not.toBeNull();
|
||||
// group 5 is the slug segment the server feeds to extractPageSlugId.
|
||||
expect(match![5]).toBe('my-page-abc123');
|
||||
});
|
||||
|
||||
it('comment-body markdown gets the same treatment (shared converter path)', () => {
|
||||
// Comment bodies go through the same markdownToProseMirrorSync; a spot-check
|
||||
// that the shared path (not a comment-only branch) does the promotion.
|
||||
const doc = markdownToProseMirrorSync('see [here](/s/team/p/xyz789)');
|
||||
const m = linkMarks(doc).find((x) => x.attrs.href === '/s/team/p/xyz789');
|
||||
expect(m.attrs.internal).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonicalize — internal:false ≡ external (#522 §11)', () => {
|
||||
it('drops editor-authored internal:false so external stays canonically equal', () => {
|
||||
// Editor stores external links with internal:false; import leaves it absent.
|
||||
const stored = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'x',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://example.com',
|
||||
internal: false,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const reimport = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'x',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://example.com',
|
||||
internal: null, // import default
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(docsCanonicallyEqual(stored, reimport)).toBe(true);
|
||||
// internal:false / target / rel all drop as defaults; only the non-default
|
||||
// href survives.
|
||||
const canon = canonicalizeContent(stored);
|
||||
const mark = canon.content[0].content[0].marks[0];
|
||||
expect(mark.attrs).toEqual({ href: 'https://example.com' });
|
||||
});
|
||||
|
||||
it('keeps internal:true through canonicalization (non-default, load-bearing)', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'x',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: { href: '/s/x/p/abc', internal: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const canon = canonicalizeContent(doc);
|
||||
const mark = canon.content[0].content[0].marks[0];
|
||||
expect(mark.attrs.internal).toBe(true);
|
||||
expect(mark.attrs.href).toBe('/s/x/p/abc');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user