From 1aa8abcd6ebc55194c67638877345166b93a2ded Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 11:08:31 +0900 Subject: [PATCH] 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. --- tests/unit/canvas-import-boundary.test.ts | 149 ++++++++++++++++++++++ tests/unit/version-card.test.tsx | 143 +++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 tests/unit/canvas-import-boundary.test.ts create mode 100644 tests/unit/version-card.test.tsx diff --git a/tests/unit/canvas-import-boundary.test.ts b/tests/unit/canvas-import-boundary.test.ts new file mode 100644 index 00000000..16d71047 --- /dev/null +++ b/tests/unit/canvas-import-boundary.test.ts @@ -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([]) + }) +}) diff --git a/tests/unit/version-card.test.tsx b/tests/unit/version-card.test.tsx new file mode 100644 index 00000000..a9d7de54 --- /dev/null +++ b/tests/unit/version-card.test.tsx @@ -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: `${id}`, + beforeXml: "", + svg: "", + summary: { ...EMPTY_SUMMARY, shapesAdded: 2 }, +}) + +function source(overrides: Partial = {}): 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( + + {ui} + , + ) +} + +describe("VersionCard", () => { + it("undoes the latest version from the source", () => { + const value = source() + const { getByTestId, container } = mount( + , + 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( + , + 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( + , + value, + ) + expect( + (getByTestId("version-restore") as HTMLButtonElement).disabled, + ).toBe(true) + const idle = source() + rerender( + + + + + , + ) + 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(, 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(, one) + expect(container.textContent).toBe("") + rerender( + + + + + , + ) + expect(container.textContent).toContain("Versions") + }) +})