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.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 899b6a3b5b
commit 1aa8abcd6e
2 changed files with 292 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
// @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",
]
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")
})
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",
alias: { "@": root },
external: ["react", "react-dom", "next/*"],
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([])
})
})
+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")
})
})