mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29:51 +08:00
- 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).
160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
// @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",
|
|
// 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)
|
|
})
|
|
})
|