feat(mcp-server): the preview is the web app's canvas: same-origin draw.io, version cards, get_selection (#984)

* refactor(canvas): decouple the canvas from the chat engine

The canvas components will be reused by the MCP server's browser shell,
which has no chat and no Next.js. They now read everything they need
from small contexts and the canvas store instead of the chat engine:

- components/canvas/versions-context.tsx: VersionsProvider and
  useVersionsContext give the version cards, the strip and the compare
  dialog a VersionsSource (versions, onCanvasId, undoneId, isBusy,
  canUndo, canRedo, restore, undo, redo). The chat fills it from the
  versions store and the engine in components/chat/chat-versions.tsx.
- stores/canvas-store.ts: isBusy and busyReason; the chat engine sets
  them while a turn runs, SelectionAsk and the version UI read them.
- components/canvas/version-card.tsx: the visual version card and the
  thumbnail, out of tool-activity.tsx; the chat's card composes it and
  adds its "Show XML" link and code panel.
- compare-dialog.tsx and version-strip.tsx move to components/canvas;
  the strip takes the minimum number of versions to show as a prop, the
  chat panel computes it from the cards it has.
- components/canvas/locale-context.tsx: LocaleProvider and useLocale,
  fed by the [lang] layout; CanvasStage no longer uses next/navigation.
  The overlay (SelectionAsk) is a slot and the wait for the saved
  language is a prop, so the shell can leave both out.
- lib/version-text.ts: describeChanges is now describeChangeSummary, so
  it can be imported next to the MCP core's describeChanges.

* test(unit): canvas import boundary and the shared version card

The boundary test scans the canvas modules' imports and bundles the canvas components with esbuild; both fail on anything from components/chat, the tool handlers, next/navigation, next/font, next/script, next/headers or a server-only module. The card test renders VersionCard and VersionStrip on a fake VersionsSource.

* refactor(scripts): pin the draw.io release in one json file and share the zip reader

* feat(mcp-server): bundle a trimmed draw.io into dist/drawio at build time

scripts/fetch-drawio.mjs downloads the pinned draw.war into a cache
(DRAWIO_WAR_CACHE, or DRAWIO_WAR for a local file), checks its SHA-256
and extracts the files named in drawio-files.txt plus a LICENSE with the
Apache-2.0 text. scripts/check-drawio-files.mjs drives the embedded
editor in headless Chromium from a full copy, records every requested
file and checks or rewrites (--update) the list.

* feat(mcp-server): serve the bundled draw.io same-origin behind an api token

GET /drawio/<path> serves dist/drawio with a MIME table, a day of
caching and nosniff; paths are normalized and never reach WEB-INF or
META-INF. The preview embeds /drawio/index.html when the copy exists and
DRAWIO_BASE_URL is unset, else the external draw.io as before (and
start_session says so). Every /api request must carry the per-process
X-Drawio-Token the page gets in its HTML; pages send
frame-ancestors 'self' and nosniff.

* build(mcp-server): require the bundled draw.io in the package and cap the tarball at 40 MB

* fix(mcp-server): review fixes for the preview token and the draw.io static files

- The preview page fetches a fresh copy of itself and retries once when an
  API request is refused with 403: another MCP process, with its own token,
  now answers on this port, and the recovery logic (recoverState) needs its
  polls to go through. The page is sent with Cache-Control: no-store.
- draw.io files are served with an ETag and Cache-Control: no-cache instead
  of a 24 hour max-age: their names do not change between versions, so a
  package upgrade must reach the browser on the next preview. HEAD and
  If-None-Match (304) are answered.
- The file read stream goes through stream.pipeline, so a read error no
  longer ends the MCP process and a client that leaves mid-download no
  longer leaks the file handle.

* fix(mcp-server): review fixes for the draw.io file list and its guard

- The list now ships the templates of Insert > Template, the PlantUML
  parser of Insert > Advanced and the template dialog's icon: the menu
  items were shown but failed with 404s. 15.1 MB packed; the tarball cap
  goes from 40 MB to 20 MB, where it still catches a list that grew by a
  whole js/ directory.
- The guard drives both features, fails when the copy it runs against
  lacks files the editor asked for or when an export does not answer,
  checks that the copy is the pinned draw.io version, and runs in CI
  (npm run check-drawio, one E2E shard) so a list regression cannot reach
  a release.

* docs: review fixes for the draw.io version pointer and the preview's embedding

- The offline deployment guides point at packages/mcp-server/src/
  drawio-version.json for the draw.war version; scripts/fetch-drawio.mjs
  no longer names it.
- The MCP README says what the bundled draw.io copy includes, and that the
  preview page cannot be shown inside an editor's built-in browser since it
  is served with frame-ancestors 'self'.

* refactor(canvas): review fixes for the busy flag, the comparison note and the bundle size check

- busyReason had no reader: the chat engine sets isBusy through the store's
  generic set, like every other flag.
- The isSameDocument comment says the two "same document" rules disagree
  in both directions, so neither is a subset of the other.
- The import boundary test bundles minified with one pako (the MCP core
  resolves its own copy) and caps the canvas core at 160 KB (134 KB now).

* fix(mcp-server): name exports at random and stamp the draw.io version into ETags

- Export requests carried a per-process counter. The preview page retries a
  result refused with 403 against the process that took over the port, so a
  late result of the old process's export could be taken for the new
  process's export with the same number. The id is a random UUID now.
- The ETag of a bundled draw.io file now starts with the version the fetch
  script stamps into dist/drawio/.version. An install that keeps the
  archive's dates (npm does not) would otherwise answer 304 for a changed
  file of the same size after an upgrade.

* docs(mcp-server): mermaid is in the bundled draw.io, which has no image proxy

- Mermaid's converter is in js/extensions.min.js, which the editor loads at
  startup, so the README no longer lists it as left out; the org chart
  layout (js/orgchart.min.js) is the example of a feature that is.
- Note that the bundled copy, like the web app's, has no /drawio/proxy:
  images from other websites, including those in some templates, are left
  out of exports and thumbnails. The guard's comment names this as a known
  limitation it does not cover.

* refactor(canvas): getDrawioSrc takes the editor's source, and CanvasStage can be given one

* feat(mcp-server): canvas shell bundled from the web app's canvas with esbuild

The shell (packages/mcp-server/shell) renders DrawioFrame inside the
shared providers without the chat: its config comes from
window.__MCP_CONFIG__, the four dictionaries ship in the bundle, the
theme is kept under an mcp: localStorage key, and system fonts stand in
for the web fonts. scripts/build-shell.mjs bundles it into dist/shell
with the root's esbuild and Tailwind; check-package caps shell.js at
1.5 MB and shell.css at 300 KB.

* feat(mcp-server): serve the canvas shell at /shell/ behind DRAWIO_PREVIEW_UI

GET /shell/ fills the page template's {{CONFIG_JSON}} with the session,
the API token, where draw.io comes from and the host's editor settings,
with the classic page's security headers; /shell/<file> serves the built
files like the draw.io copy. start_session opens the shell when
DRAWIO_PREVIEW_UI=shell; the classic page stays the default.

* test(mcp-server): open the built shell with playwright

npm run test:e2e starts dist/index.js over the MCP SDK with
DRAWIO_PREVIEW_UI=shell, opens the URL start_session returns in headless
Chromium, and checks that the bundled draw.io loads without console
errors and that the theme toggle switches draw.io in place.

* ci: install the root dependencies before building the mcp package

The shell build needs the root's esbuild, Tailwind and React.

* feat(canvas): DiagramProvider exports with options and shows a transient document

requestExport(request, timeoutMs) runs one draw.io export with the
request's own parameters (a PNG's pageId and width) and resolves with its
answer (data, or xml for format "xml"); null when draw.io does not answer.
showTransient(xml) puts a document on the canvas for an export only: it is
not recorded, its autosaves are ignored, and the next loadDiagram brings
the user back to the page they were on. The MCP's canvas shell answers the
server's export requests with both.

* feat(mcp-server): the shell syncs with the server like the classic page

shell/mcp-sync-core.ts ports preview.js's protocol without the page or
React: polling GET /api/state every 2 s with the state id and version,
pushes of the user's edits with their base version and state, 409 and 413
handling, recovery of a recreated session (the tab's copy goes to History),
the token refresh after a 403, sync and export requests (PNG by page id,
SVG of another page through a transient one-page projection with autosave
ignored, the 600 ms wait for icons), thumbnails for History, and History
reads and restores that name the state the list belongs to.

A server write goes on the canvas as one undo step (loadDiagram commit,
with the change marked) when decideLoad finds it changes only the page on
screen, keeps the file variables and needs no load-only setting; the other
pages are compared with the MCP core's contentFingerprint. draw.io's own
re-serialized copy of such a write is recognized with isSameDocument and
not pushed back as an edit.

shell/use-mcp-sync.ts mounts the sync once inside DiagramProvider, reads
draw.io's autosave and load messages, and reports the connection state,
which the shell's status bar shows; notices come as toasts from the
dictionaries.

* test(mcp-server): port the preview protocol tests to the sync core

The recovery, thumbnail, size limit and History cases of
tests/unit/mcp-preview-recovery.test.ts, driven against mcp-sync-core.ts
with a stubbed server and canvas, plus the stale 409 recovery, an export
request answered once, a projection that ignores edits and restores the
document, the thumbnail of a write loaded before draw.io was ready, writes
taken in place as commits (draw.io's own copy is not pushed), and the
commit-or-load decision table of decideLoad.

* test(mcp-server): e2e of the shell's sync with the server

The shell connects, shows what create_new_diagram draws, marks an
edit_diagram change and takes it back with one Ctrl+Z (which get_diagram
then reflects), pushes a shape inserted in the editor, serves
screenshot_diagram, and shows another page only for its SVG export before
the user's page comes back.

* fix(mcp-server): review fixes for the shell's sync core

A recovery loads the server's diagram in full and waits for draw.io's
load report, as the classic page does: whether the canvas takes a write
in place is decided inside DiagramProvider, and an autosave of the
canvas being replaced went to the server as an edit when the decision
was wrong. A paper size change alone is pushed again: autosaves are
compared as documents only for draw.io's own copy of a committed write.
A projection stays on screen until draw.io reports the restore load
(5 s at most), the whole export ends in 15 s even when its result POST
hangs, and an export in flight is dropped when draw.io starts over. A
refused poll shows the tab offline. With several pages and the page on
screen unknown, a write loads in full. hasLoadOnlySettings and the
highlight of an AI change are shared with the editor bridge.

* fix(mcp-server): review fixes for the preview URL and the theme menu

The stalled-tab note names the page start_session opened (the shell
with DRAWIO_PREVIEW_UI=shell) instead of the classic page. With the
shell not built, start_session opens the classic page and the server
says so at startup, as it does for a missing dist/drawio. BROWSER=none
skips the system browser, as Vite and CRA dev servers do: the e2e tests
set it. Without a fixed DRAWIO_UI, the shell's draw.io URL carries
themes=1 so Extras > Theme is offered, as on the classic page.

* fix(mcp-server): review fixes for the shell's build

tsc -p shell checked nothing: the inherited exclude dropped every shell
file; the shell's tsconfig now excludes node_modules only. Tailwind scans
app-toaster.tsx too, so the sync notices look like the web app's toasts.
The bundle keeps its dependencies' license comments (esbuild appends them
at the end). postcss, which build-shell.mjs loads from the root, is a
declared devDependency.

* ci: run the shell's browser tests on the packed tarball, publish on shared code changes

The e2e job's first shard builds the MCP package, packs it, installs the
tarball in an empty directory and runs packages/mcp-server/tests/e2e
against the installed dist/index.js (MCP_SERVER_ENTRY), so a shell that
builds but fails in the browser, or a tarball missing a runtime
dependency, fails the PR. Its traces go with the Playwright report. The
publish workflow also triggers on the web app code the shell is bundled
from (components, contexts, hooks, lib, stores, globals.css) and the
root lockfile.

* fix(canvas): ignore autosaves of the document a pending full load replaces

An autosave that arrives after loadDiagram sent a full load, and before draw.io reports it, is of the canvas being replaced (draw.io reports each load, in order). Until now it overwrote chartXMLRef: in the MCP shell, a late autosave of a one-page projection replaced the cached document, and a remount of an external draw.io then put the projection on the canvas as the whole document.

* fix(mcp-server): review fixes for the shell's commits and export timeout

A write taken in place that leaves the canvas as it is gets no autosave from draw.io, so the marker for its copy stayed armed and ate the user's next paper-size edit: arm it only when the write changed the document. The 15 s export timeout now retires the export's sequence number, so a result POST that answers late no longer restores the document a second time.

* ci: create the tarball directory before npm pack

npm pack does not create its --pack-destination; on a fresh runner the step failed with ENOENT before the packed shell tests ran.

* feat(mcp-server): history entries name their maker, and /api/history the entry on the canvas

Each History entry records who made it (a user edit, a recovering tab's
copy, a restored copy; none for the AI's writes), when, and its page
count. GET /api/history now also returns each entry's XML, the first entry
with the same content (a restore adds a copy of an older one), the state
the ids belong to, and the entry whose content the canvas shows, by the
rule restore_version uses. The shell's version cards read these; the
classic page keeps using index, id and svg.

* feat(mcp-server): version cards in the shell from the server's History

The shell's VersionsSource (shell/node-versions-source.ts) reads GET
/api/history through the sync and shows one version per distinct content
(a restore's copy folds into the entry it copies; the blank page is none),
numbered as they first appeared, with what changed since the one before.
The entry the server says is on the canvas marks the version; undo and
redo of the newest version restore the one before it, or it again, through
POST /api/restore, so hand edits stay as versions of their own. The cards
sit in a panel beside the canvas (toggle in the header); the strip and
Compare come from the shared canvas components. The sync tells listeners
once per server version, and when History changed without one, so the
list is read again only then.

* test(mcp-server): e2e of the shell's version cards

In a session of its own: three writes give three cards; undoing the newest
restores the one before on the server too, redo brings it back, a hand edit
turns the undo into a restore, and restoring the first version keeps that
edit as a version of its own, which restore_version also finds.

* feat(mcp-server): get_selection reads the cells the user selected in the shell

The server asks the preview tab for the selection the way it asks for an export (a random request id in GET /api/state, the answer in a POST with that id, 10 s to answer). The shell answers through the editor bridge with each cell's id, label, an edge's ends, a shape's geometry and the page on screen; without a same-origin editor it says so, and the tool names the external draw.io. The classic page cannot answer, so the tool says that at once. screenshot_diagram's description now says a page selector renders that page without changing the page on screen (PNG exports by pageId already did).

* test(mcp-server): selection requests, get_selection texts, and page screenshots that leave the view alone

Unit tests for the request plumbing (random id, one answer, timeout), the tool's texts (cells, nothing selected, external draw.io) and the shell's side (one answer per request, after draw.io is up, never during a projection); the wiring test starts a session with BROWSER=none to see the no-tab message. Shell e2e: cells selected in the editor reach get_selection; a PNG of another page differs from the page on screen, which stays.

* feat(mcp-server): the canvas shell is the default preview, the classic page behind DRAWIO_PREVIEW_UI=classic

* docs: the MCP preview is the web app's canvas; get_selection, DRAWIO_BASE_URL and the classic page

* chore(mcp-server): version 0.4.0; check-package requires the draw.io version stamp

* ci: an mcp-shell job builds the package, runs its tests, the draw.io file guard and the shell e2e

* fix(mcp-server): review fixes for the version cards and History

The shell's version cards (shell/node-versions-source.ts):
- a version's change and undo target are the state it replaced, the
  History entry right before its first copy, not the card before it: after
  a restore those differ, and undo went to the wrong version (and not
  where restore_version steps_back=1 goes)
- a card restores the newest copy of its content, as restore_version
  does, so page settings the user changed (a "user" copy) are kept
- a blank page after a drawing is a clear of the canvas, a version of its
  own; only the blank page before any drawing is hidden
- numbers and changes are keyed by content, not by the first copy's id,
  so a version keeps them when its first copy drops out of the server's
  20-entry buffer; the caches start over for another server state (the
  process restarted: entry ids name other content)

The server's History (src/history.ts):
- firstCopyIds compares each entry with the first of every group only: a
  bare model matches any page name, so "same content" is not transitive,
  and a card could show one document and restore another
- the time and pages fields had no reader; pages parsed every XML once
  more on every write

Reading History (src/http-server.ts, shell/mcp-sync-core.ts):
- GET /api/state and a push's answer carry a History key (entry count,
  newest id, the entry on the canvas); the shell reads History again only
  when it changes, so a hand edit no longer downloads every entry's XML and
  thumbnail
- a failed History read is told again at the next poll
- a History list from a state the poll has not seen yet is dropped

* fix(mcp-server): review fixes for get_selection

Server (src/http-server.ts, src/index.ts, src/selection.ts,
src/new-diagram.ts):
- overlapping get_selection calls take turns (readSelection, one slot per
  session as the export slot) instead of replacing each other's request,
  which left one of them with a false "tab not in front" timeout
- an answer is taken only while a request is pending (both ids undefined
  compared equal)
- the tool sees the page start_session actually opens: with dist/shell
  missing the classic page is in use, which never answers, so the tool
  says so instead of timing out
- the result lists at most 100 cells and counts the rest: a whole large
  diagram selected would fill the model's context
- every <diagram> the model sends without an id gets one, so the page id
  the shell reports exists in the server's document

Shell (shell/mcp-sync-core.ts, shell/use-mcp-sync.ts,
contexts/diagram-context.tsx, lib/drawio/editor-bridge.ts):
- an answer whose POST failed is sent again at the next poll
- no answer while a full load has yet to reach the editor: it still shows
  the previous document, whose cells and pages the answer would name
- a hidden tab (the same session open twice) answers a poll later, so the
  tab in front answers first; alone, it still answers within the timeout
- a cell's container is reported by the model's isLayer, not by comparing
  with the default parent, which is the group the user entered

* fix(mcp-server): review fixes for the shell page

- a download button in the header opens the web app's export dialog
  (.drawio, .png, .svg, .drawio.svg), which the classic page had and the
  shell lacked when it became the default
- the shell asks draw.io for the custom library menu (libraries=1), as
  the classic page did; the web app keeps libraries=0
- the newest card no longer shows "Rendering preview" for good: the sync
  takes the thumbnail of a diagram the server recovered from its file
  (saved without pictures) while the canvas kept it, and of a write whose
  picture was skipped because an edit came first, once the canvas shows
  the write again
- e2e: the get_selection test covers a shape in a container the user
  entered; a download test saves a .drawio file

* ci: the version bump reminder watches every path publish-mcp.yml does, and compares the version field

The reminder step left out the root lockfile and scripts/, which change
what is bundled into the shell and trigger the publish workflow; and it
took any change of packages/mcp-server/package.json (a dependency bump)
for a version bump. It now compares the version field between the two
commits.

* fix(mcp-server): get_selection lists every selected id, with detail for the first 100

* fix(mcp-server): a hidden tab's delayed selection answer checks the canvas again first

* fix(mcp-server): the version cards keep only the current History in memory, picture the newest copy, and keep a clear the buffer scrolled to

* fix(mcp-server): save who made each History entry with it, so the cards look the same after a restart

* fix(mcp-server): the download waits for a page export's projection to end, and the dialog's styles are bundled

* fix(mcp-server): bundle open.html and js/open.js, the picker of Open Library from > Browser

* fix(mcp-server): the shell shows the versions once, in the side panel

* test(mcp-server): get_selection wiring test expects the classic page's answer when the shell is not built
This commit is contained in:
Dayuan Jiang
2026-10-11 21:27:11 +09:00
committed by GitHub
parent 07f3e3c2a3
commit 4c7b2cc0c5
88 changed files with 9740 additions and 561 deletions
+162
View File
@@ -0,0 +1,162 @@
// @vitest-environment node
/**
* The canvas modules are reused by the MCP server's browser shell, which
* has no chat and no Next.js. They must not import the chat, the tool
* handlers, Next-only modules or server-only code. Checked twice: the
* import statements of each file, and what esbuild pulls into a bundle of
* the canvas components.
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
import path from "node:path"
import { build } from "esbuild"
import { describe, expect, it } from "vitest"
const root = process.cwd()
const SCANNED = [
"components/canvas",
"contexts/diagram-context.tsx",
"lib/drawio",
"lib/diagram-pages.ts",
"lib/diagram-diff.ts",
"stores/canvas-store.ts",
"stores/versions-store.ts",
// The MCP's browser shell, built from these modules by esbuild
"packages/mcp-server/shell",
]
const FORBIDDEN = [
/^@\/components\/chat\//,
/^@\/hooks\/use-diagram-tool-handlers/,
/^next\/navigation$/,
/^next\/font(\/|$)/,
/^next\/script$/,
/^next\/headers$/,
/^server-only$/,
]
function listFiles(entry: string): string[] {
const full = path.join(root, entry)
if (statSync(full).isFile()) return [entry]
return readdirSync(full, { recursive: true })
.map(String)
.filter((name) => /\.tsx?$/.test(name))
.map((name) => path.join(entry, name))
}
/** Module specifiers a file imports at runtime (type-only imports do not
* reach a bundle) */
function valueImports(source: string): string[] {
const code = source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "")
const specifiers: string[] = []
const statements =
/(?:^|\n)\s*(import|export)\s+([\s\S]*?)\s*from\s*["']([^"']+)["']|(?:^|\n)\s*import\s+["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)/g
for (const match of code.matchAll(statements)) {
const [, keyword, clause, from, bare, dynamic] = match
if (keyword && /^type\s/.test(clause)) continue
specifiers.push(from ?? bare ?? dynamic)
}
return specifiers
}
/** The file an "@/..." or relative specifier points at, when it is one of
* ours */
function resolveLocal(fromFile: string, specifier: string): string | null {
let base: string
if (specifier.startsWith("@/")) base = path.join(root, specifier.slice(2))
else if (specifier.startsWith(".")) {
base = path.resolve(root, path.dirname(fromFile), specifier)
} else return null
for (const candidate of [
base,
`${base}.ts`,
`${base}.tsx`,
path.join(base, "index.ts"),
path.join(base, "index.tsx"),
]) {
if (existsSync(candidate) && statSync(candidate).isFile()) {
return path.relative(root, candidate)
}
}
return null
}
const isServerOnly = (file: string) =>
/^\s*import\s+["']server-only["']/m.test(
readFileSync(path.join(root, file), "utf8"),
)
describe("canvas import boundary", () => {
const files = SCANNED.flatMap(listFiles)
it("scans the canvas modules", () => {
expect(files).toContain("components/canvas/canvas-stage.tsx")
expect(files).toContain("lib/drawio/editor-bridge.ts")
expect(files).toContain("packages/mcp-server/shell/main.tsx")
})
it.each(files)("%s imports no chat or Next-only module", (file) => {
const source = readFileSync(path.join(root, file), "utf8")
const offending = valueImports(source).filter((specifier) => {
if (FORBIDDEN.some((rule) => rule.test(specifier))) return true
const target = resolveLocal(file, specifier)
if (!target) return false
return target.startsWith("components/chat/") || isServerOnly(target)
})
expect(offending).toEqual([])
})
it("bundles the canvas components without the chat", async () => {
const result = await build({
entryPoints: [
"components/canvas/canvas-stage.tsx",
"components/canvas/compare-dialog.tsx",
"components/canvas/version-card.tsx",
"components/canvas/version-strip.tsx",
],
absWorkingDir: root,
// Several entries need an outdir, even when nothing is written
outdir: "out",
bundle: true,
write: false,
metafile: true,
platform: "browser",
format: "esm",
jsx: "automatic",
// The MCP core (packages/mcp-server/src) resolves pako from its
// own node_modules; one copy, as the shell build must do
alias: { "@": root, pako: path.join(root, "node_modules/pako") },
external: ["react", "react-dom", "next/*"],
minify: true,
logLevel: "silent",
})
const inputs = result.metafile.inputs
const bundled = Object.keys(inputs)
expect(bundled.length).toBeGreaterThan(10)
expect(
bundled.filter(
(file) =>
file.startsWith("components/chat/") ||
file.includes("node_modules/server-only/"),
),
).toEqual([])
const nextImports = Object.entries(inputs).flatMap(([file, input]) =>
input.imports
.map((entry) => entry.path)
.filter((specifier) =>
FORBIDDEN.some((rule) => rule.test(specifier)),
)
.map((specifier) => `${file} -> ${specifier}`),
)
expect(nextImports).toEqual([])
// The canvas core stays small enough for the MCP's browser shell
// (134 KB minified, pako once)
const stage = Object.values(result.metafile.outputs).find(
(output) =>
output.entryPoint === "components/canvas/canvas-stage.tsx",
)
expect(stage?.bytes).toBeLessThan(160 * 1024)
})
})
+207
View File
@@ -0,0 +1,207 @@
import { act, renderHook } from "@testing-library/react"
import type React from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { DiagramProvider, useDiagram } from "@/contexts/diagram-context"
import { useCanvasStore } from "@/stores/canvas-store"
vi.mock("sonner", () => ({ toast: { success: vi.fn() } }))
// No editor to drive: every load is a full load, and the page to show after
// it is recorded
const selectPage = vi.fn((_id: string) => true)
vi.mock("@/lib/drawio/editor-bridge", () => ({
canReplaceDiagram: () => false,
commitDiagram: vi.fn(),
previewDiagram: vi.fn(),
resetPreview: vi.fn(),
revertPreview: vi.fn(),
selectPage: (id: string) => selectPage(id),
}))
const doc = (pages: string) => `<mxfile>${pages}</mxfile>`
const page = (id: string, label: string) =>
`<diagram id="${id}" name="${id}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="c-${id}" value="${label}" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel></diagram>`
// The provider with a stand-in draw.io that records loads and exports
function setup() {
const { result } = renderHook(() => useDiagram(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<DiagramProvider>{children}</DiagramProvider>
),
})
const requests: { format: string; message: string; pageId?: string }[] = []
const loads: string[] = []
result.current.drawioRef.current = {
exportDiagram: (r: any) => requests.push(r),
load: ({ xml }: { xml: string }) => loads.push(xml),
} as any
const reply = (
request: { message: string },
data: string | undefined,
xml?: string,
) =>
act(() =>
result.current.handleDiagramExport({
event: "export",
data,
xml,
format: "png",
message: request,
} as any),
)
return { result, requests, loads, reply }
}
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
useCanvasStore.setState({ currentPageId: null })
})
describe("requestExport", () => {
it("passes the request on and resolves with its own answer", async () => {
const { result, requests, reply } = setup()
const png = result.current.requestExport({
format: "png",
pageId: "p2",
width: 700,
})
const xml = result.current.requestExport({ format: "xml" })
expect(requests).toMatchObject([
{ format: "png", pageId: "p2", width: 700 },
{ format: "xml" },
])
// The XML export answers first, with the document instead of data
reply(requests[1], undefined, "<mxfile>doc</mxfile>")
expect(await xml).toEqual({
data: undefined,
xml: "<mxfile>doc</mxfile>",
})
reply(requests[0], "data:image/png;base64,AAAA")
expect(await png).toEqual({
data: "data:image/png;base64,AAAA",
xml: undefined,
})
})
it("resolves to null when draw.io does not answer in time", async () => {
vi.useFakeTimers()
const { result, requests } = setup()
const pending = result.current.requestExport({ format: "svg" }, 1000)
await vi.advanceTimersByTimeAsync(1000)
expect(await pending).toBeNull()
// A late answer goes nowhere
expect(
result.current.exportResolversRef.current[requests[0].message],
).toBeUndefined()
})
it("resolves to null without draw.io", async () => {
const { result } = setup()
result.current.drawioRef.current = null
expect(await result.current.requestExport({ format: "svg" })).toBeNull()
})
})
describe("showTransient", () => {
type Result = ReturnType<typeof setup>["result"]
// Load the document, with draw.io's "load" reports: its first one makes
// the provider send the document once more, which draw.io reports too
const start = (result: Result, xml: string) =>
act(() => {
result.current.loadDiagram(xml, true)
result.current.onDrawioLoad()
result.current.onDrawioLoad()
})
const loaded = (result: Result) => act(() => result.current.onDrawioLoad())
const autosave = (result: Result, xml: string) =>
act(() => result.current.handleDiagramAutoSave({ xml }))
it("shows the document without recording it, and ignores its autosaves until the next load", () => {
const { result, loads } = setup()
start(result, doc(page("p1", "real")))
expect(result.current.chartXML).toContain('value="real"')
act(() => {
result.current.showTransient(doc(page("p2", "projection")))
})
loaded(result)
expect(loads.at(-1)).toContain('value="projection"')
expect(result.current.chartXML).toContain('value="real"')
autosave(result, doc(page("p2", "projection edited")))
expect(result.current.chartXML).toContain('value="real"')
// The real document comes back: recorded, and once draw.io reports
// the load, saves count again
act(() => {
result.current.loadDiagram(doc(page("p1", "real again")), true)
})
loaded(result)
expect(result.current.chartXML).toContain('value="real again"')
autosave(result, doc(page("p1", "real edited")))
expect(result.current.chartXML).toContain('value="real edited"')
})
it("keeps the document over a projection autosave that comes before draw.io reports the load back", () => {
const { result, loads } = setup()
const twoPages = doc(page("p1", "one") + page("p2", "two"))
start(result, twoPages)
act(() => {
result.current.showTransient(doc(page("p2", "two")))
})
loaded(result)
// The restore load is sent; the user's last drag on the projection
// is autosaved before draw.io gets to the load
act(() => {
result.current.loadDiagram(twoPages, true, "load")
})
autosave(result, doc(page("p2", "two moved")))
const isDocument = (xml: string | undefined) =>
!!xml && xml.includes('id="p1"') && !xml.includes("two moved")
expect(isDocument(result.current.chartXMLRef.current)).toBe(true)
loaded(result)
expect(isDocument(result.current.chartXMLRef.current)).toBe(true)
// The frame starts over (an external draw.io on a theme switch) and
// gets the document, not the projection
act(() => {
result.current.resetDrawioReady()
})
loaded(result)
expect(isDocument(loads.at(-1))).toBe(true)
})
it("ignores an autosave of the document a full load replaces", () => {
const { result } = setup()
start(result, doc(page("p1", "first")))
act(() => {
result.current.loadDiagram(doc(page("p1", "second")), true, "load")
})
// draw.io autosaved the user's last change of the first document
// before it got to the load
autosave(result, doc(page("p1", "first moved")))
expect(result.current.chartXML).toContain('value="second"')
loaded(result)
autosave(result, doc(page("p1", "second moved")))
expect(result.current.chartXML).toContain('value="second moved"')
})
it("goes back to the page the user was on before the projection", () => {
const { result } = setup()
const twoPages = doc(page("p1", "one") + page("p2", "two"))
act(() => {
result.current.loadDiagram(twoPages, true)
result.current.onDrawioLoad()
})
// The user is on page two; the projection shows page one
useCanvasStore.setState({ currentPageId: "p2" })
act(() => {
result.current.showTransient(doc(page("p1", "one")))
})
useCanvasStore.setState({ currentPageId: "p1" })
selectPage.mockClear()
act(() => {
result.current.loadDiagram(twoPages, true)
result.current.onDrawioLoad()
})
expect(selectPage).toHaveBeenCalledWith("p2")
})
})
+693
View File
@@ -0,0 +1,693 @@
import { act, cleanup, renderHook } from "@testing-library/react"
import type React from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { DictionaryProvider } from "@/hooks/use-dictionary"
import type { Dictionary } from "@/lib/i18n/dictionaries"
import en from "@/lib/i18n/dictionaries/en.json"
import type {
HistoryEntryInfo,
HistoryList,
McpSync,
} from "@/packages/mcp-server/shell/mcp-sync-core"
import {
buildVersions,
newVersionCache as cache,
useNodeVersions,
} from "@/packages/mcp-server/shell/node-versions-source"
import { versionLabel } from "@/packages/mcp-server/shell/versions-panel"
import { useCanvasStore } from "@/stores/canvas-store"
// The shell's version cards come from the MCP server's History (GET
// /api/history), read through the sync; restores go back through it
afterEach(cleanup)
const toasts = vi.hoisted(() => ({ success: vi.fn() }))
vi.mock("sonner", () => ({ toast: toasts }))
const cell = (id: string, label: string) =>
`<mxCell id="${id}" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>`
const doc = (cells: string, pageId = "p1") =>
`<mxfile><diagram id="${pageId}" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
const A = doc(cell("a", "A"))
const AB = doc(cell("a", "A") + cell("b", "B"))
const ABC = doc(cell("a", "A") + cell("b", "B") + cell("c", "C"))
function entry(
id: number,
xml: string,
extra: Partial<HistoryEntryInfo> = {},
): HistoryEntryInfo {
return {
id,
index: id,
svg: "",
xml,
source: null,
firstId: id,
...extra,
}
}
const list = (
entries: HistoryEntryInfo[],
currentId: number | null,
): HistoryList => ({ entries, stateId: "S1", currentId })
describe("buildVersions", () => {
it("makes one numbered version per History entry, with what changed since the one before", () => {
const { versions, onCanvasId } = buildVersions(
list([entry(0, A), entry(1, AB, { svg: "data:AB" })], 1),
cache(),
)
expect(versions.map((v) => [v.id, v.number])).toEqual([
["0", 1],
["1", 2],
])
expect(versions[0].fromScratch).toBe(true)
expect(versions[0].summary.shapesAdded).toBe(1)
expect(versions[1].fromScratch).toBe(false)
expect(versions[1].summary.shapesAdded).toBe(1)
expect(versions[1].pageId).toBe("p1")
expect(versions[1].beforeXml).toBe(A)
expect(versions[1].svg).toBe("data:AB")
expect(onCanvasId).toBe("1")
})
it("leaves the blank page out, and counts the first drawing from it", () => {
// The server keeps the blank page the tab showed when the first
// write replaces it; a page the user named is a version
const blank = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const named = blank.replace('name="Page-1"', 'name="Plan"')
const { versions, onCanvasId } = buildVersions(
list([entry(0, blank), entry(1, A), entry(2, named)], 2),
cache(),
)
expect(versions.map((v) => [v.id, v.number])).toEqual([
["1", 1],
["2", 2],
])
expect(versions[0].fromScratch).toBe(true)
expect(onCanvasId).toBe("2")
// Blank on the canvas: no version is
expect(buildVersions(list([entry(0, blank)], 0), cache())).toEqual({
versions: [],
onCanvasId: "0",
})
})
it("folds a restore's copy into the version it copies, where that one is", () => {
// History: A, AB, A again (restored); the canvas shows A
const { versions, onCanvasId } = buildVersions(
list(
[
entry(0, A),
entry(1, AB),
entry(2, A, {
firstId: 0,
svg: "data:A",
source: "restore",
}),
],
2,
),
cache(),
)
expect(versions.map((v) => v.id)).toEqual(["0", "1"])
// The copy's picture stands in for the first copy's missing one
expect(versions[0].svg).toBe("data:A")
expect(onCanvasId).toBe("0")
})
it("keeps the numbers once given, and gives new versions the next", () => {
const kept = cache()
buildVersions(list([entry(0, A), entry(1, AB)], 1), kept)
// The oldest entry dropped out of the server's buffer
const { versions } = buildVersions(
list([entry(1, AB), entry(2, ABC)], 2),
kept,
)
expect(versions.map((v) => v.number)).toEqual([2, 3])
})
it("keeps in its cache only the content the list still has, and never hands out a number twice", () => {
// 25 writes, the server's buffer holding the last 20: what dropped
// out (its full documents) goes, the numbering runs on
const kept = cache()
const docs = Array.from({ length: 25 }, (_, i) =>
doc(
Array.from({ length: i + 1 }, (_, j) =>
cell(`c${j}`, `C${j}`),
).join(""),
),
)
const entries = docs.map((xml, i) => entry(i, xml))
let versions: ReturnType<typeof buildVersions>["versions"] = []
for (let n = 1; n <= 25; n++) {
const window = entries.slice(Math.max(0, n - 20), n)
versions = buildVersions(list(window, n - 1), kept).versions
}
expect(versions).toHaveLength(20)
expect(versions.at(-1)?.number).toBe(25)
expect(kept.numbers.size).toBe(20)
expect(kept.changes.size).toBe(20)
// Only the ones before the drawing restored: a restore of the
// newest content gives it no new number
const back = buildVersions(
list(
[
...entries.slice(6),
entry(25, docs[24], { firstId: 24, source: "restore" }),
],
25,
),
kept,
).versions
expect(back.map((v) => v.number)).not.toContain(26)
const AD = doc(cell("a", "A") + cell("d", "D"))
const next = buildVersions(
list([...entries.slice(7), entry(26, AD)], 26),
kept,
).versions
expect(next.at(-1)?.number).toBe(26)
})
it("keeps showing a clear once the drawing before it dropped out of the server's buffer", () => {
const blank = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const kept = cache()
const later = Array.from({ length: 19 }, (_, i) =>
entry(
i + 3,
doc(
Array.from({ length: i + 1 }, (_, j) =>
cell(`c${j}`, `C${j}`),
).join(""),
),
),
)
// The blank page, A, the user cleared the canvas, 19 writes
const first = buildVersions(
list(
[
entry(0, blank),
entry(1, A),
entry(2, blank, { firstId: 0, source: "user" }),
...later.slice(0, 17),
],
19,
),
kept,
)
expect(first.versions[1]).toMatchObject({ id: "2", number: 2 })
// The buffer moved on: the clear is now the oldest entry, and a
// restore of it after that folds into it
const scrolled = buildVersions(
list(
[
entry(2, blank, { firstId: 2, source: "user" }),
...later,
entry(22, blank, { firstId: 2, source: "restore" }),
],
22,
),
kept,
)
expect(scrolled.versions[0]).toMatchObject({ id: "2", number: 2 })
expect(scrolled.versions[1].beforeId).toBe("2")
expect(scrolled.onCanvasId).toBe("2")
// A tab opened now never showed it: the blank is the one it starts with
const fresh = buildVersions(
list([entry(2, blank, { firstId: 2 }), ...later], 21),
cache(),
)
expect(fresh.versions[0].id).toBe("3")
})
it("counts a write from the state it replaced, not from the card before, after a restore", () => {
// History: A, AB, ABC, A again (restored), AD: the AI drew D on A
const AD = doc(cell("a", "A") + cell("d", "D"))
const { versions } = buildVersions(
list(
[
entry(0, A),
entry(1, AB),
entry(2, ABC),
entry(3, A, { firstId: 0, source: "restore" }),
entry(4, AD),
],
4,
),
cache(),
)
expect(versions.map((v) => v.id)).toEqual(["0", "1", "2", "4"])
const latest = versions[3]
expect(latest.beforeXml).toBe(A)
expect(latest.summary.shapesAdded).toBe(1)
expect(latest.summary.shapesRemoved).toBe(0)
expect(latest.beforeId).toBe("0")
// The first drawing replaced nothing a card shows
expect(versions[0].beforeId).toBeNull()
expect(versions[1].beforeId).toBe("0")
})
it("restores the newest copy of a version: a later copy may carry the user's page settings", () => {
// The user set a background on A (kept as a "user" entry, the same
// content to the server), then the AI added B
const yellow = A.replace(
"<mxGraphModel>",
'<mxGraphModel background="#ffcc00">',
)
const { versions } = buildVersions(
list(
[
entry(0, A),
entry(1, yellow, { firstId: 0, source: "user" }),
entry(2, AB),
],
2,
),
cache(),
)
expect(versions).toHaveLength(2)
expect(versions[0].id).toBe("0")
expect(versions[0].entryId).toBe(1)
expect(versions[0].xml).toBe(yellow)
expect(versions[0].source).toBeNull()
})
it("pictures a version as its newest copy looks", () => {
// The thumbnail shows the background: the yellow copy's picture
// goes with the yellow document the card restores, not the white
// one's, and no picture at all when the yellow copy has none
const yellow = A.replace(
"<mxGraphModel>",
'<mxGraphModel background="#ffcc00">',
)
const pictured = buildVersions(
list(
[
entry(0, A, { svg: "data:white" }),
entry(1, yellow, {
firstId: 0,
source: "user",
svg: "data:yellow",
}),
entry(2, AB, { svg: "data:AB" }),
],
2,
),
cache(),
)
expect(pictured.versions[0].svg).toBe("data:yellow")
const unpictured = buildVersions(
list(
[
entry(0, A, { svg: "data:white" }),
entry(1, yellow, { firstId: 0, source: "user" }),
entry(2, AB, { svg: "data:AB" }),
],
2,
),
cache(),
)
expect(unpictured.versions[0].svg).toBe("")
})
it("shows a clear of the canvas as a version, hiding only the blank page before any drawing", () => {
const blank = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// The server folds the cleared page into the first blank (same
// content); then AB is restored
const entries = [
entry(0, blank),
entry(1, A),
entry(2, AB),
entry(3, blank, { firstId: 0 }),
entry(4, AB, { firstId: 2, source: "restore" }),
]
const cleared = buildVersions(list(entries.slice(0, 4), 3), cache())
expect(cleared.versions.map((v) => v.id)).toEqual(["1", "2", "3"])
expect(cleared.onCanvasId).toBe("3")
expect(cleared.versions[2].beforeId).toBe("2")
expect(cleared.versions[2].summary.shapesRemoved).toBe(2)
const back = buildVersions(list(entries, 4), cache())
expect(back.versions.map((v) => v.id)).toEqual(["1", "2", "3"])
expect(back.onCanvasId).toBe("2")
// A second clear is a version of its own; a restore of a clear is
// a copy of the newest one
const twice = buildVersions(
list(
[
...entries,
entry(5, blank, { firstId: 0 }),
entry(6, blank, { firstId: 0, source: "restore" }),
],
6,
),
cache(),
)
expect(twice.versions.map((v) => [v.id, v.number])).toEqual([
["1", 1],
["2", 2],
["3", 3],
["5", 4],
])
expect(twice.onCanvasId).toBe("5")
})
it("keeps a version's number and change when its first copy drops out of the server's buffer", () => {
const kept = cache()
const docs = Array.from({ length: 20 }, (_, i) =>
doc(
Array.from({ length: i + 1 }, (_, j) =>
cell(`c${j}`, `C${j}`),
).join(""),
),
)
const full = docs.map((xml, i) => entry(i, xml))
buildVersions(list(full, 19), kept)
// v1 restored: its copy is added and the original, the oldest
// entry, drops out; the copy is now the first with that content
const after = [
...full.slice(1),
entry(20, docs[0], { firstId: 20, source: "restore" }),
]
const { versions, onCanvasId } = buildVersions(list(after, 20), kept)
const v1 = versions.find((v) => v.id === "20")
expect(v1?.number).toBe(1)
expect(v1?.fromScratch).toBe(true)
expect(v1?.summary.shapesAdded).toBe(1)
expect(v1?.beforeXml).toBe("")
expect(versions.map((v) => v.number)).not.toContain(21)
expect(onCanvasId).toBe("20")
})
it("lets the newest version on the canvas wait for its picture, older ones show none", () => {
const { versions } = buildVersions(
list([entry(0, A), entry(1, AB)], 1),
cache(),
)
expect(versions[0].svg).toBe("")
expect(versions[1].svg).toBeUndefined()
const edited = buildVersions(
list([entry(0, A), entry(1, AB)], null),
cache(),
)
expect(edited.versions[1].svg).toBe("")
})
it("labels a version by who made it and what it did", () => {
const dict = en as unknown as Dictionary
const { versions } = buildVersions(
list(
[entry(0, A), entry(1, AB, { source: "user" }), entry(2, ABC)],
2,
),
cache(),
)
expect(versions.map((v) => versionLabel(v, dict))).toEqual([
"Drew the diagram",
"Changed by hand",
"Changed the diagram",
])
})
})
/** A stand-in for the sync: History as the server would answer, restores
* recorded */
function fakeSync(initial: HistoryList) {
let current = initial
const listeners = new Set<() => void>()
const restores: { id: number; stateId: string | null }[] = []
let restoreResult: "ok" | "stale" | "failed" = "ok"
const sync = {
fetchHistory: vi.fn(async () => current),
restoreEntry: vi.fn(async (id: number, stateId: string | null) => {
restores.push({ id, stateId })
return restoreResult
}),
onServerChange: (listener: () => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
} as unknown as McpSync
return {
sync,
restores,
/** The server changed: the list it would answer now */
serve(next: HistoryList) {
current = next
for (const listener of listeners) listener()
},
failRestores(result: "stale" | "failed") {
restoreResult = result
},
}
}
const wrapper = ({ children }: { children: React.ReactNode }) => (
<DictionaryProvider dictionary={en as unknown as Dictionary}>
{children}
</DictionaryProvider>
)
const flush = () => act(() => Promise.resolve())
describe("useNodeVersions", () => {
it("reads History when the sync starts and whenever the server changed", async () => {
const server = fakeSync(list([entry(0, A)], 0))
const { result } = renderHook(() => useNodeVersions(server.sync), {
wrapper,
})
await flush()
expect(result.current.versions.map((v) => v.number)).toEqual([1])
expect(result.current.onCanvasId).toBe("0")
expect(result.current.canUndo).toBe(false)
await act(async () =>
server.serve(list([entry(0, A), entry(1, AB)], 1)),
)
expect(result.current.versions.map((v) => v.number)).toEqual([1, 2])
expect(result.current.onCanvasId).toBe("1")
expect(result.current.canUndo).toBe(true)
expect(result.current.canRedo).toBe(false)
})
it("numbers the History of another server state anew", async () => {
// The process restarted: entry ids start over and name other content
const server = fakeSync(list([entry(0, A), entry(1, AB)], 1))
const { result } = renderHook(() => useNodeVersions(server.sync), {
wrapper,
})
await flush()
expect(result.current.versions.map((v) => v.number)).toEqual([1, 2])
const AB2 = doc(cell("a", "A") + cell("b", "B"), "p2")
const ABC2 = doc(cell("a", "A") + cell("b", "B") + cell("c", "C"), "p2")
await act(async () =>
server.serve({
entries: [entry(0, AB2), entry(1, ABC2)],
stateId: "S2",
currentId: 1,
}),
)
expect(result.current.versions.map((v) => v.number)).toEqual([1, 2])
const [first, second] = result.current.versions
expect(first.fromScratch).toBe(true)
expect(first.summary.shapesAdded).toBe(2)
expect(second.pageId).toBe("p2")
})
it("undoes to the version the newest one replaced, after a restore in between", async () => {
// History: A, AB, ABC, A again (restored), AD; undo from AD goes to
// A (its newest copy), as restore_version does, not to ABC
const AD = doc(cell("a", "A") + cell("d", "D"))
const server = fakeSync(
list(
[
entry(0, A),
entry(1, AB),
entry(2, ABC),
entry(3, A, { firstId: 0, source: "restore" }),
entry(4, AD),
],
4,
),
)
const { result } = renderHook(() => useNodeVersions(server.sync), {
wrapper,
})
await flush()
expect(result.current.canUndo).toBe(true)
await act(async () => {
result.current.undo()
})
expect(server.restores).toEqual([{ id: 3, stateId: "S1" }])
// Back at A: AD is the undone one
await act(async () =>
server.serve(
list(
[
entry(0, A),
entry(1, AB),
entry(2, ABC),
entry(3, A, { firstId: 0, source: "restore" }),
entry(4, AD),
entry(5, A, { firstId: 0, source: "restore" }),
],
5,
),
),
)
expect(result.current.onCanvasId).toBe("0")
expect(result.current.undoneId).toBe("4")
expect(result.current.canRedo).toBe(true)
})
it("has no versions without a sync", () => {
const { result } = renderHook(() => useNodeVersions(null), { wrapper })
expect(result.current.versions).toEqual([])
expect(result.current.onCanvasId).toBeNull()
})
it("restores through the sync with the list's state, and says so", async () => {
const server = fakeSync(list([entry(0, A), entry(1, AB)], 1))
const { result } = renderHook(() => useNodeVersions(server.sync), {
wrapper,
})
await flush()
await act(async () => {
result.current.restore("0")
})
expect(server.restores).toEqual([{ id: 0, stateId: "S1" }])
expect(toasts.success).toHaveBeenCalledWith("Restored v1", {
duration: 4000,
})
// The canvas was busy for the restore, and is free again
expect(useCanvasStore.getState().isBusy).toBe(false)
})
it("undoes the newest version by restoring the one before, and redoes it", async () => {
// The server keeps the state it left as a copy of the entry
const server = fakeSync(list([entry(0, A), entry(1, AB)], 1))
const { result } = renderHook(() => useNodeVersions(server.sync), {
wrapper,
})
await flush()
await act(async () => {
result.current.undo()
})
expect(server.restores).toEqual([{ id: 0, stateId: "S1" }])
await act(async () =>
server.serve(
list(
[entry(0, A), entry(1, AB), entry(2, A, { firstId: 0 })],
2,
),
),
)
expect(result.current.versions).toHaveLength(2)
expect(result.current.onCanvasId).toBe("0")
expect(result.current.undoneId).toBe("1")
expect(result.current.canUndo).toBe(false)
expect(result.current.canRedo).toBe(true)
await act(async () => {
result.current.redo()
})
expect(server.restores.at(-1)).toEqual({ id: 1, stateId: "S1" })
await act(async () =>
server.serve(
list(
[
entry(0, A),
entry(1, AB),
entry(2, A, { firstId: 0 }),
entry(3, AB, { firstId: 1 }),
],
3,
),
),
)
expect(result.current.onCanvasId).toBe("1")
expect(result.current.undoneId).toBeNull()
expect(result.current.canUndo).toBe(true)
})
it("offers neither undo nor redo after a hand edit, and keeps that edit as a version once a write follows", async () => {
const server = fakeSync(list([entry(0, A), entry(1, AB)], null))
const { result } = renderHook(() => useNodeVersions(server.sync), {
wrapper,
})
await flush()
expect(result.current.canUndo).toBe(false)
expect(result.current.canRedo).toBe(false)
expect(result.current.undoneId).toBeNull()
// The AI writes: the server kept the hand edit first
const manual = doc(cell("a", "A") + cell("b", "B") + cell("m", "M"))
await act(async () =>
server.serve(
list(
[
entry(0, A),
entry(1, AB),
entry(2, manual, { source: "user" }),
entry(3, ABC),
],
3,
),
),
)
expect(result.current.versions.map((v) => v.source)).toEqual([
null,
null,
"user",
null,
])
})
it("does not restore twice at once, nor what is not a version", async () => {
const server = fakeSync(list([entry(0, A), entry(1, AB)], 1))
let resolveRestore: (r: "ok") => void = () => {}
server.sync.restoreEntry = vi.fn(
() =>
new Promise<"ok">((resolve) => {
resolveRestore = resolve
}),
) as unknown as McpSync["restoreEntry"]
const { result } = renderHook(() => useNodeVersions(server.sync), {
wrapper,
})
await flush()
act(() => {
result.current.restore("0")
})
await flush()
expect(result.current.isBusy).toBe(true)
act(() => {
result.current.restore("0")
result.current.restore("missing")
})
expect(server.sync.restoreEntry).toHaveBeenCalledTimes(1)
await act(async () => resolveRestore("ok"))
expect(result.current.isBusy).toBe(false)
})
it("stays quiet when a restore was refused, and reads the list again", async () => {
const server = fakeSync(list([entry(0, A), entry(1, AB)], 1))
server.failRestores("stale")
const { result } = renderHook(() => useNodeVersions(server.sync), {
wrapper,
})
await flush()
toasts.success.mockClear()
const reads = (server.sync.fetchHistory as ReturnType<typeof vi.fn>)
.mock.calls.length
await act(async () => {
result.current.restore("0")
})
expect(toasts.success).not.toHaveBeenCalled()
expect(
(server.sync.fetchHistory as ReturnType<typeof vi.fn>).mock.calls
.length,
).toBe(reads + 1)
})
})
+1
View File
@@ -12,6 +12,7 @@ const html = readFileSync(join(dir, "index.html"), "utf8")
.replace("{{DRAWIO_URL}}", "about:blank")
.replace("{{SESSION_JSON}}", '""')
.replace("{{ORIGIN_JSON}}", '"https://embed.diagrams.net"')
.replace("{{TOKEN_JSON}}", '"test-token"')
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) =>
m[1].replace("{{SCRIPT}}", ""),
)
+58 -2
View File
@@ -14,6 +14,7 @@ const html = readFileSync(join(dir, "index.html"), "utf8")
.replace("{{DRAWIO_URL}}", "about:blank")
.replace("{{SESSION_JSON}}", '"mcp-test"')
.replace("{{ORIGIN_JSON}}", JSON.stringify(DRAWIO))
.replace("{{TOKEN_JSON}}", JSON.stringify("test-token"))
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) =>
m[1].replace("{{SCRIPT}}", ""),
)
@@ -21,10 +22,12 @@ const preview = readFileSync(join(dir, "preview.js"), "utf8")
const pageListeners: Array<[string, EventListener]> = []
type Answer = { status: number; body: unknown }
// A string body is sent as it is (the page's HTML); anything else as JSON
type Answer = { status: number; body?: unknown }
interface Call {
url: string
method: string
headers: Record<string, string>
body: any
answer: (a: Answer) => void
fail: () => void
@@ -47,10 +50,19 @@ function openPage() {
calls.push({
url,
method: init?.method ?? "GET",
headers: (init?.headers ?? {}) as Record<
string,
string
>,
body: init?.body ? JSON.parse(String(init.body)) : null,
answer: ({ status, body }) =>
resolve(
new Response(JSON.stringify(body), { status }),
new Response(
typeof body === "string"
? body
: JSON.stringify(body),
{ status },
),
),
fail: () => reject(new TypeError("Failed to fetch")),
})
@@ -152,6 +164,50 @@ describe("MCP preview after the server recreated its session", () => {
})
})
it("fetches the new process's token after a 403 and then recovers", async () => {
const t = await inStep()
// The user edits; the push reaches a new process, which has its own
// token, and is refused, as is the next poll
t.fromDrawio({ event: "autosave", xml: "<mxfile>B</mxfile>" })
t.fromDrawio({ event: "export", data: "<svg/>" })
await t.settle()
const refused = t.next("POST")
expect(refused.headers["X-Drawio-Token"]).toBe("test-token")
refused.answer({ status: 403 })
await t.settle()
// The page reads its own HTML again for the token
const reload = t.next("GET")
expect(reload.url).toBe(location.href)
expect(reload.headers["X-Drawio-Token"]).toBeUndefined()
reload.answer({
status: 200,
body: '<script>let API_TOKEN = "0123abcd";</script>',
})
await t.settle()
// The push is retried with the new token; the new process refuses
// it for its state id, and the poll that follows recovers
const retried = t.next("POST")
expect(retried.headers["X-Drawio-Token"]).toBe("0123abcd")
retried.answer({
status: 409,
body: { error: "Session was recreated" },
})
await t.settle()
const poll = t.next("GET")
expect(poll.headers["X-Drawio-Token"]).toBe("0123abcd")
poll.answer(state("S2", 1, "<mxfile>A</mxfile>"))
await t.settle()
await t.settle()
expect(t.page.read().stateId).toBe("S2")
const push = t.next("POST")
expect(push.headers["X-Drawio-Token"]).toBe("0123abcd")
expect(push.body).toMatchObject({
xml: "<mxfile>B</mxfile>",
stateId: "S2",
baseVersion: 1,
})
})
it("shows the server's diagram and keeps the tab's in History", async () => {
const t = await inStep()
const poll = t.page.poll()
+160
View File
@@ -0,0 +1,160 @@
/**
* The MCP's canvas shell takes its settings from the config the server
* writes into the page, and picks its language and theme in the browser.
*/
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { describe, expect, it } from "vitest"
import { getDrawioSrc } from "@/lib/drawio/drawio-config"
import { i18n } from "@/lib/i18n/config"
import {
getShellDictionary,
matchLocale,
pickLocale,
} from "@/packages/mcp-server/shell/dictionary"
import { readShellConfig } from "@/packages/mcp-server/shell/runtime-config"
import {
isDarkPreferred,
THEME_STORAGE_KEY,
} from "@/packages/mcp-server/shell/theme"
const shellDir = join(process.cwd(), "packages/mcp-server/shell")
describe("shell runtime config", () => {
it("takes the server's values", () => {
const config = readShellConfig({
sessionId: "mcp-abc",
token: "0123",
apiBase: "/api",
drawioBaseUrl: "https://embed.diagrams.net/",
drawioUi: "sketch",
drawioLang: "ja",
drawioDark: "dark",
lang: "ja",
})
expect(config.sessionId).toBe("mcp-abc")
expect(config.drawioBaseUrl).toBe("https://embed.diagrams.net/")
expect(config.drawioUi).toBe("sketch")
expect(config.drawioDark).toBe("dark")
})
it("fills in defaults for what is missing or malformed", () => {
expect(readShellConfig(undefined)).toEqual({
sessionId: "",
token: "",
apiBase: "/api",
drawioBaseUrl: "/drawio/index.html",
drawioUi: "",
drawioLang: "",
drawioDark: "auto",
lang: "",
})
const odd = readShellConfig({
sessionId: 42,
drawioDark: "sometimes",
token: "t",
})
expect(odd.sessionId).toBe("")
expect(odd.drawioDark).toBe("auto")
expect(odd.token).toBe("t")
})
})
describe("shell draw.io URL", () => {
it("offers draw.io's theme menu unless a theme is fixed", () => {
// Embedded draw.io shows Extras > Theme only with themes=1 and no ui
const free = new URL(
getDrawioSrc({ editorUrl: "/drawio/index.html" }, false),
)
expect(free.searchParams.get("themes")).toBe("1")
expect(free.searchParams.get("ui")).toBeNull()
const fixed = new URL(
getDrawioSrc(
{ editorUrl: "/drawio/index.html", ui: "kennedy" },
false,
),
)
expect(fixed.searchParams.get("ui")).toBe("kennedy")
expect(fixed.searchParams.get("themes")).toBeNull()
})
it("offers draw.io's custom libraries when asked, as the classic page did", () => {
const withLibraries = new URL(
getDrawioSrc(
{ editorUrl: "/drawio/index.html", libraries: true },
false,
),
)
expect(withLibraries.searchParams.get("libraries")).toBe("1")
const without = new URL(
getDrawioSrc({ editorUrl: "/drawio/index.html" }, false),
)
expect(without.searchParams.get("libraries")).toBe("0")
})
})
describe("shell language", () => {
it("maps language tags to the dictionaries we have", () => {
expect(matchLocale("en-US")).toBe("en")
expect(matchLocale("zh")).toBe("zh")
expect(matchLocale("zh-CN")).toBe("zh")
expect(matchLocale("zh-Hans-CN")).toBe("zh")
expect(matchLocale("zh-TW")).toBe("zh-Hant")
expect(matchLocale("zh-Hant")).toBe("zh-Hant")
expect(matchLocale("ZH-HK")).toBe("zh-Hant")
expect(matchLocale("ja-JP")).toBe("ja")
expect(matchLocale("de")).toBeNull()
expect(matchLocale("")).toBeNull()
})
it("prefers the server's language, then the browser's, then English", () => {
expect(pickLocale("zh-tw", ["en-US"])).toBe("zh-Hant")
expect(pickLocale("", ["de-DE", "ja-JP", "en"])).toBe("ja")
expect(pickLocale("de", ["fr"])).toBe("en")
expect(pickLocale("", [])).toBe("en")
})
it("has every dictionary, with the shell's own texts", () => {
for (const locale of i18n.locales) {
const dict = getShellDictionary(locale)
expect(Object.keys(dict.shell).sort(), locale).toEqual([
"aiChanged",
"connected",
"hideVersions",
"historyChanged",
"offline",
"restoreFailed",
"restoredFromFile",
"showVersions",
"toggleTheme",
"tooLarge",
"unreachable",
"userEdit",
"waiting",
])
expect(dict.shell.waiting, locale).toBeTruthy()
expect(dict.workspace.canvasLoading, locale).toBeTruthy()
}
})
})
describe("shell theme", () => {
it("follows the stored choice, then the server, then the system", () => {
expect(isDarkPreferred("true", "light", false)).toBe(true)
expect(isDarkPreferred("false", "dark", true)).toBe(false)
expect(isDarkPreferred(null, "dark", false)).toBe(true)
expect(isDarkPreferred(null, "light", true)).toBe(false)
expect(isDarkPreferred(null, "auto", true)).toBe(true)
expect(isDarkPreferred(null, "auto", false)).toBe(false)
expect(isDarkPreferred("maybe", "auto", true)).toBe(true)
})
it("is applied before the first paint by the page's own script", () => {
const html = readFileSync(join(shellDir, "index.html"), "utf8")
expect(THEME_STORAGE_KEY.startsWith("mcp:")).toBe(true)
expect(html).toContain(`localStorage.getItem("${THEME_STORAGE_KEY}")`)
expect(html).toContain("window.__MCP_CONFIG__ = {{CONFIG_JSON}};")
expect(html).toContain('src="/shell/shell.js"')
expect(html).toContain('href="/shell/shell.css"')
})
})
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -6,7 +6,7 @@ import enDict from "@/lib/i18n/dictionaries/en.json"
import jaDict from "@/lib/i18n/dictionaries/ja.json"
import zhDict from "@/lib/i18n/dictionaries/zh.json"
import zhHantDict from "@/lib/i18n/dictionaries/zh-Hant.json"
import { describeChanges, describeTotals } from "@/lib/version-text"
import { describeChangeSummary, describeTotals } from "@/lib/version-text"
import {
MAX_VERSIONS,
useVersionsStore,
@@ -39,7 +39,7 @@ describe("withPageDefaults", () => {
})
})
describe("describeChanges", () => {
describe("describeChangeSummary", () => {
const en = {
shapesAddedOne: "added 1 shape",
shapesAddedOther: "added {count} shapes",
@@ -51,7 +51,7 @@ describe("describeChanges", () => {
it("uses singular and plural forms and sentence case", () => {
expect(
describeChanges(
describeChangeSummary(
{ ...EMPTY_SUMMARY, shapesAdded: 2, edgesChanged: 1 },
en,
),
@@ -59,7 +59,7 @@ describe("describeChanges", () => {
})
it("says so when nothing changed", () => {
expect(describeChanges({ ...EMPTY_SUMMARY }, en)).toBe(
expect(describeChangeSummary({ ...EMPTY_SUMMARY }, en)).toBe(
"No visible changes",
)
})
@@ -71,7 +71,7 @@ describe("describeChanges", () => {
listSeparator: ",",
}
expect(
describeChanges(
describeChangeSummary(
{ ...EMPTY_SUMMARY, shapesAdded: 3, edgesChanged: 1 },
zh,
),
+143
View File
@@ -0,0 +1,143 @@
import { cleanup, fireEvent, render } from "@testing-library/react"
import type React from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { VersionCard } from "@/components/canvas/version-card"
import { VersionStrip } from "@/components/canvas/version-strip"
import {
type VersionItem,
VersionsProvider,
type VersionsSource,
} from "@/components/canvas/versions-context"
import { DictionaryProvider } from "@/hooks/use-dictionary"
import { EMPTY_SUMMARY } from "@/lib/diagram-diff"
import type { Dictionary } from "@/lib/i18n/dictionaries"
import en from "@/lib/i18n/dictionaries/en.json"
afterEach(cleanup)
// jsdom has no scrolling; the strip scrolls to its newest version
Element.prototype.scrollTo = vi.fn()
const version = (id: string, number: number): VersionItem => ({
id,
number,
xml: `<mxfile>${id}</mxfile>`,
beforeXml: "",
svg: "",
summary: { ...EMPTY_SUMMARY, shapesAdded: 2 },
})
function source(overrides: Partial<VersionsSource> = {}): VersionsSource {
return {
versions: [version("v1", 1), version("v2", 2)],
onCanvasId: "v2",
undoneId: null,
isBusy: false,
canUndo: true,
canRedo: false,
restore: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
...overrides,
}
}
function mount(ui: React.ReactNode, value: VersionsSource) {
return render(
<DictionaryProvider dictionary={en as unknown as Dictionary}>
<VersionsProvider value={value}>{ui}</VersionsProvider>
</DictionaryProvider>,
)
}
describe("VersionCard", () => {
it("undoes the latest version from the source", () => {
const value = source()
const { getByTestId, container } = mount(
<VersionCard
version={value.versions[1]}
isLatest
label="Changed the diagram"
data-tool-state="output-available"
/>,
value,
)
expect(container.textContent).toContain("v2")
expect(container.textContent).toContain("Added 2 shapes")
expect(getByTestId("version-card").dataset.toolState).toBe(
"output-available",
)
fireEvent.click(getByTestId("version-undo"))
expect(value.undo).toHaveBeenCalled()
expect(value.redo).not.toHaveBeenCalled()
})
it("redoes an undone change and says so", () => {
const value = source({
onCanvasId: null,
undoneId: "v2",
canUndo: false,
canRedo: true,
})
const { getByTestId, container } = mount(
<VersionCard version={value.versions[1]} isLatest label="x" />,
value,
)
expect(container.textContent).toContain("Change undone")
fireEvent.click(getByTestId("version-undo"))
expect(value.redo).toHaveBeenCalled()
})
it("restores an older version; not while busy", () => {
const value = source({ isBusy: true })
const { getByTestId, rerender } = mount(
<VersionCard
version={value.versions[0]}
isLatest={false}
label="x"
/>,
value,
)
expect(
(getByTestId("version-restore") as HTMLButtonElement).disabled,
).toBe(true)
const idle = source()
rerender(
<DictionaryProvider dictionary={en as unknown as Dictionary}>
<VersionsProvider value={idle}>
<VersionCard
version={idle.versions[0]}
isLatest={false}
label="x"
/>
</VersionsProvider>
</DictionaryProvider>,
)
fireEvent.click(getByTestId("version-restore"))
expect(idle.restore).toHaveBeenCalledWith("v1")
})
})
describe("VersionStrip", () => {
it("shows one thumbnail per version, the one on the canvas marked", () => {
const { getAllByTestId } = mount(<VersionStrip />, source())
const thumbs = getAllByTestId("version-thumb")
expect(thumbs).toHaveLength(2)
expect(thumbs[1].title).toContain("Canvas now")
expect(thumbs[0].title).not.toContain("Canvas now")
})
it("hides below the minimum", () => {
const one = source({ versions: [version("v1", 1)] })
const { container, rerender } = mount(<VersionStrip />, one)
expect(container.textContent).toBe("")
rerender(
<DictionaryProvider dictionary={en as unknown as Dictionary}>
<VersionsProvider value={one}>
<VersionStrip minVersions={1} />
</VersionsProvider>
</DictionaryProvider>,
)
expect(container.textContent).toContain("Versions")
})
})