mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29:51 +08:00
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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user