diff --git a/.gitignore b/.gitignore index 0e8010a0..6893a058 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ packages/*/dist /coverage /playwright-report/ /test-results/ +packages/*/playwright-report/ +packages/*/test-results/ # next.js /.next/ diff --git a/biome.json b/biome.json index bf56b8b0..b5c2eeff 100644 --- a/biome.json +++ b/biome.json @@ -11,6 +11,7 @@ "**", "!public", "!packages/mcp-server/src/preview", + "!packages/mcp-server/shell/index.html", "!lib/model-catalog.json" ] }, diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index 6ec1cbfe..c37fa777 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -699,5 +699,9 @@ "recent": "Recent diagrams", "drawYourself": "Start with a blank canvas", "openFileHint": "Keep working on a diagram you have" + }, + "shell": { + "waiting": "Waiting for the MCP server…", + "toggleTheme": "Switch between light and dark" } } diff --git a/lib/i18n/dictionaries/ja.json b/lib/i18n/dictionaries/ja.json index 8d0f71b5..c5191529 100644 --- a/lib/i18n/dictionaries/ja.json +++ b/lib/i18n/dictionaries/ja.json @@ -699,5 +699,9 @@ "recent": "最近の図", "drawYourself": "白紙のキャンバスから始める", "openFileHint": "手元の図の続きを編集" + }, + "shell": { + "waiting": "MCP サーバーを待っています…", + "toggleTheme": "ライトとダークを切り替え" } } diff --git a/lib/i18n/dictionaries/zh-Hant.json b/lib/i18n/dictionaries/zh-Hant.json index 4b126674..03239fb2 100644 --- a/lib/i18n/dictionaries/zh-Hant.json +++ b/lib/i18n/dictionaries/zh-Hant.json @@ -699,5 +699,9 @@ "recent": "最近的圖紙", "drawYourself": "從空白畫布開始", "openFileHint": "接著編輯已有的圖" + }, + "shell": { + "waiting": "正在等待 MCP 伺服器…", + "toggleTheme": "切換淺色或深色" } } diff --git a/lib/i18n/dictionaries/zh.json b/lib/i18n/dictionaries/zh.json index b71d48bb..3865c7ef 100644 --- a/lib/i18n/dictionaries/zh.json +++ b/lib/i18n/dictionaries/zh.json @@ -699,5 +699,9 @@ "recent": "最近的图纸", "drawYourself": "从空白画布开始", "openFileHint": "接着编辑已有的图" + }, + "shell": { + "waiting": "正在等待 MCP 服务器…", + "toggleTheme": "切换浅色或深色" } } diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 1c4433bb..cb1e1aa1 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -8,13 +8,14 @@ "next-ai-drawio-mcp": "./dist/index.js" }, "scripts": { - "build": "tsc && node scripts/copy-assets.mjs && node scripts/fetch-drawio.mjs", + "build": "tsc && tsc -p shell && node scripts/copy-assets.mjs && node scripts/fetch-drawio.mjs && node scripts/build-shell.mjs", "check-drawio": "node scripts/check-drawio-files.mjs", "check-package": "node scripts/check-package.mjs", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "playwright test", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/packages/mcp-server/scripts/build-shell.mjs b/packages/mcp-server/scripts/build-shell.mjs new file mode 100644 index 00000000..64d0c3bf --- /dev/null +++ b/packages/mcp-server/scripts/build-shell.mjs @@ -0,0 +1,80 @@ +// Build the canvas shell (shell/, the web app's canvas without the chat) +// into dist/shell: shell.js with esbuild, shell.css with Tailwind, and the +// page template. Needs the repository checked out with its root +// devDependencies installed (esbuild, Tailwind, React and the shared +// components come from there), like copy-assets.mjs needs ../../docs. +import { cpSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { createRequire } from "node:module" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const pkg = join(dirname(fileURLToPath(import.meta.url)), "..") +const root = join(pkg, "../..") +const shell = join(pkg, "shell") +const out = join(pkg, "dist/shell") + +// The root's copies: the shell's React must be the one the shared +// components import, and the MCP core's pako and saxes should not be +// bundled twice (packages/mcp-server has its own node_modules) +const rootRequire = createRequire(join(root, "package.json")) +const rootPackage = (name) => + dirname(rootRequire.resolve(`${name}/package.json`)) +const { build } = await import(rootRequire.resolve("esbuild")) +const postcss = (await import(rootRequire.resolve("postcss"))).default +const tailwindcss = (await import(rootRequire.resolve("@tailwindcss/postcss"))) + .default + +mkdirSync(out, { recursive: true }) + +const result = await build({ + entryPoints: [join(shell, "main.tsx")], + outfile: join(out, "shell.js"), + bundle: true, + minify: true, + format: "esm", + target: "es2022", + platform: "browser", + jsx: "automatic", + legalComments: "none", + metafile: true, + absWorkingDir: root, + alias: { + "@": root, + pako: rootPackage("pako"), + saxes: rootPackage("saxes"), + }, + define: { + "process.env.NODE_ENV": '"production"', + // The shell has no base path and takes its draw.io from the config + // the server writes into the page, not from the web app's build + // settings + "process.env.NEXT_PUBLIC_BASE_PATH": '""', + "process.env.NEXT_PUBLIC_DRAWIO_BASE_URL": '""', + }, +}) +const js = readFileSync(join(out, "shell.js"), "utf8") +// A reference that the defines above missed would throw in the browser +// ("process is not defined") +const leftover = js.match(/process\.env\.[A-Z_]+/) +if (leftover) { + throw new Error(`shell.js still reads ${leftover[0]}; define it above`) +} + +const cssFile = join(shell, "shell.css") +const css = await postcss([ + // Tailwind scans the shell directory on its own; shell.css names the + // shared component directories with @source + tailwindcss({ base: shell, optimize: { minify: true } }), +]).process(readFileSync(cssFile, "utf8"), { + from: cssFile, + to: join(out, "shell.css"), +}) +writeFileSync(join(out, "shell.css"), css.css) + +cpSync(join(shell, "index.html"), join(out, "index.html")) + +const kb = (n) => `${(n / 1024).toFixed(0)} KB` +const bundled = Object.values(result.metafile.outputs).find((o) => o.entryPoint) +console.log( + `shell: shell.js ${kb(bundled.bytes)}, shell.css ${kb(Buffer.byteLength(css.css))} -> ${out}`, +) diff --git a/packages/mcp-server/scripts/check-package.mjs b/packages/mcp-server/scripts/check-package.mjs index 274b2fda..8580ce81 100644 --- a/packages/mcp-server/scripts/check-package.mjs +++ b/packages/mcp-server/scripts/check-package.mjs @@ -12,7 +12,16 @@ const REQUIRED = [ "dist/drawio/index.html", "dist/drawio/js/app.min.js", "dist/drawio/LICENSE", + "dist/shell/index.html", + "dist/shell/shell.js", + "dist/shell/shell.css", ] +// The canvas shell (708 KB and 50 KB when it was added); a jump means a +// dependency of the shared components grew the bundle +const MAX_FILE_BYTES = { + "dist/shell/shell.js": 1.5 * 1024 * 1024, + "dist/shell/shell.css": 300 * 1024, +} // The bundled draw.io is most of the package (15.1 MB packed with draw.io // v32.0.2); a jump means the file list grew more than intended const MAX_TARBALL_BYTES = 20 * 1024 * 1024 @@ -29,6 +38,16 @@ if (missing.length > 0) { process.exit(1) } const mb = (n) => (n / 1048576).toFixed(1) +const kb = (n) => (n / 1024).toFixed(0) +for (const [path, limit] of Object.entries(MAX_FILE_BYTES)) { + const size = pack.files.find((f) => f.path === path).size + if (size > limit) { + console.error( + `${path} is ${kb(size)} KB, over the ${kb(limit)} KB limit`, + ) + process.exit(1) + } +} if (pack.size > MAX_TARBALL_BYTES) { console.error( `npm package is ${mb(pack.size)} MB packed, over the ${mb(MAX_TARBALL_BYTES)} MB limit`, diff --git a/packages/mcp-server/shell/canvas-shell.tsx b/packages/mcp-server/shell/canvas-shell.tsx new file mode 100644 index 00000000..cd31ffc2 --- /dev/null +++ b/packages/mcp-server/shell/canvas-shell.tsx @@ -0,0 +1,90 @@ +import { Moon, Sun } from "lucide-react" +import { useMemo } from "react" +import { CanvasStage } from "@/components/canvas/canvas-stage" +import { CompareDialog } from "@/components/canvas/compare-dialog" +import { VersionStrip } from "@/components/canvas/version-strip" +import { + VersionsProvider, + type VersionsSource, +} from "@/components/canvas/versions-context" +import { useDictionary } from "@/hooks/use-dictionary" +import type { DrawioSource } from "@/lib/drawio/drawio-config" +import { useSettingsStore } from "@/stores/settings-store" +import type { ShellConfig } from "./runtime-config" +import { setDark } from "./theme" + +// No versions until the shell reads the server's History (a later step) +const NO_VERSIONS: VersionsSource = { + versions: [], + onCanvasId: null, + undoneId: null, + isBusy: false, + canUndo: false, + canRedo: false, + restore: () => {}, + undo: () => {}, + redo: () => {}, +} + +/** + * The MCP preview: the web app's canvas without the chat. A slim bar names + * the session and the sync state; the versions strip and Compare come from + * the shared canvas components. + */ +export function CanvasShell({ config }: { config: ShellConfig }) { + const dict = useDictionary() + const isDark = useSettingsStore((s) => s.isDark) + const drawioSource = useMemo( + () => ({ + editorUrl: config.drawioBaseUrl, + ui: config.drawioUi || undefined, + lang: config.drawioLang || undefined, + }), + [config], + ) + + return ( + +
+
+ + Next AI Draw.io + + {config.sessionId && ( + + {config.sessionId.slice(-8)} + + )} + + {dict.shell.waiting} + + +
+ +
+ +
+ +
+
+ ) +} diff --git a/packages/mcp-server/shell/dictionary.ts b/packages/mcp-server/shell/dictionary.ts new file mode 100644 index 00000000..ccdbcb32 --- /dev/null +++ b/packages/mcp-server/shell/dictionary.ts @@ -0,0 +1,46 @@ +import { i18n, type Locale } from "@/lib/i18n/config" +import type { Dictionary } from "@/lib/i18n/dictionaries" +import en from "@/lib/i18n/dictionaries/en.json" +import ja from "@/lib/i18n/dictionaries/ja.json" +import zh from "@/lib/i18n/dictionaries/zh.json" +import zhHant from "@/lib/i18n/dictionaries/zh-Hant.json" + +// The web app loads one dictionary on the server (lib/i18n/dictionaries.ts +// is server-only); the shell ships all four and picks in the browser +const DICTIONARIES: Record = { + en, + zh, + ja, + "zh-Hant": zhHant, +} + +/** Our locale for a language tag ("zh-TW", "ja-JP", "en-US"), or null */ +export function matchLocale(tag: string): Locale | null { + const lower = tag.trim().toLowerCase() + if (!lower) return null + if (/^zh-(hant|tw|hk|mo)\b/.test(lower)) return "zh-Hant" + const base = lower.split("-")[0] + if (base === "zh") return "zh" + if (base === "ja") return "ja" + if (base === "en") return "en" + return null +} + +/** + * The shell's language: the server's DRAWIO_LANG when it is one of ours, + * else the browser's first language we have, else English. + */ +export function pickLocale( + configLang: string, + browserLangs: readonly string[], +): Locale { + for (const tag of [configLang, ...browserLangs]) { + const locale = matchLocale(tag) + if (locale) return locale + } + return i18n.defaultLocale +} + +export function getShellDictionary(locale: Locale): Dictionary { + return DICTIONARIES[locale] +} diff --git a/packages/mcp-server/shell/index.html b/packages/mcp-server/shell/index.html new file mode 100644 index 00000000..b9d475f7 --- /dev/null +++ b/packages/mcp-server/shell/index.html @@ -0,0 +1,31 @@ + + + + + + Next AI Draw.io + + + + + + +
+ + + diff --git a/packages/mcp-server/shell/main.tsx b/packages/mcp-server/shell/main.tsx new file mode 100644 index 00000000..bcc2d4d8 --- /dev/null +++ b/packages/mcp-server/shell/main.tsx @@ -0,0 +1,28 @@ +// Entry of the MCP's canvas shell, bundled by scripts/build-shell.mjs into +// dist/shell/shell.js and served at /shell/ by http-server.ts +import { createRoot } from "react-dom/client" +import { LocaleProvider } from "@/components/canvas/locale-context" +import { DiagramProvider } from "@/contexts/diagram-context" +import { DictionaryProvider } from "@/hooks/use-dictionary" +import { CanvasShell } from "./canvas-shell" +import { getShellDictionary, pickLocale } from "./dictionary" +import { pageShellConfig } from "./runtime-config" +import { initTheme } from "./theme" + +const config = pageShellConfig() +const locale = pickLocale(config.lang, navigator.languages) +// The stylesheet picks the CJK fonts by the document's language +document.documentElement.lang = locale +initTheme(config.drawioDark) + +const root = document.getElementById("root") +if (!root) throw new Error("The shell page has no #root element") +createRoot(root).render( + + + + + + + , +) diff --git a/packages/mcp-server/shell/runtime-config.ts b/packages/mcp-server/shell/runtime-config.ts new file mode 100644 index 00000000..c3bbc3d8 --- /dev/null +++ b/packages/mcp-server/shell/runtime-config.ts @@ -0,0 +1,64 @@ +/** + * What the MCP server writes into the shell page as window.__MCP_CONFIG__ + * (http-server.ts fills the {{CONFIG_JSON}} placeholder of index.html). + */ +export interface ShellConfig { + sessionId: string + /** Every /api request carries it in the X-Drawio-Token header */ + token: string + /** Prefix of the server's API paths */ + apiBase: string + /** The editor page: the bundled copy's index.html on this origin, or an + * external draw.io */ + drawioBaseUrl: string + /** draw.io theme fixed by DRAWIO_UI; "" leaves the pick to draw.io */ + drawioUi: string + /** Editor language fixed by DRAWIO_LANG, in draw.io's naming; "" leaves + * the pick to draw.io */ + drawioLang: string + /** DRAWIO_DARK: the theme when the user has not chosen one here */ + drawioDark: "dark" | "light" | "auto" + /** DRAWIO_LANG as given, for the shell's own texts */ + lang: string +} + +const DEFAULTS: ShellConfig = { + sessionId: "", + token: "", + apiBase: "/api", + drawioBaseUrl: "/drawio/index.html", + drawioUi: "", + drawioLang: "", + drawioDark: "auto", + lang: "", +} + +/** + * The page's config with every field present: a missing or malformed one + * (another server version) takes its default. + */ +export function readShellConfig(source: unknown): ShellConfig { + const given = + source && typeof source === "object" + ? (source as Record) + : {} + const config = { ...DEFAULTS } + for (const key of Object.keys(DEFAULTS) as (keyof ShellConfig)[]) { + const value = given[key] + if (key === "drawioDark") { + if (value === "dark" || value === "light" || value === "auto") { + config.drawioDark = value + } + } else if (typeof value === "string") { + config[key] = value + } + } + return config +} + +/** The config the server wrote into this page */ +export function pageShellConfig(): ShellConfig { + return readShellConfig( + (window as { __MCP_CONFIG__?: unknown }).__MCP_CONFIG__, + ) +} diff --git a/packages/mcp-server/shell/shell.css b/packages/mcp-server/shell/shell.css new file mode 100644 index 00000000..70ac7dd1 --- /dev/null +++ b/packages/mcp-server/shell/shell.css @@ -0,0 +1,18 @@ +/* + * Stylesheet of the MCP's canvas shell: the web app's theme and Tailwind, + * with the utility classes of the shared components the shell bundles. + * Built by scripts/build-shell.mjs; Tailwind scans this directory on its + * own, and the shared components through the @source lines. + */ +@import "../../../app/globals.css"; +@source "../../../components/canvas"; +@source "../../../components/ui"; +@source "../../../components/ai-elements"; + +/* System fonts stand in for the web fonts the Next.js layout loads */ +:root { + --font-latin: + ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} diff --git a/packages/mcp-server/shell/theme.ts b/packages/mcp-server/shell/theme.ts new file mode 100644 index 00000000..eb25e14f --- /dev/null +++ b/packages/mcp-server/shell/theme.ts @@ -0,0 +1,61 @@ +import { useSettingsStore } from "@/stores/settings-store" +import type { ShellConfig } from "./runtime-config" + +/** + * localStorage key of the user's theme choice here: "true" dark, "false" + * light, none: the server's DRAWIO_DARK, else the system. Its own prefix, + * so a web app dev server on the same port keeps its own settings. + * index.html repeats the key in its inline script (run before this bundle). + */ +export const THEME_STORAGE_KEY = "mcp:dark-mode" + +/** Whether the shell is dark, from the choice stored here, the server's + * setting and the system */ +export function isDarkPreferred( + stored: string | null, + drawioDark: ShellConfig["drawioDark"], + systemDark: boolean, +): boolean { + if (stored === "true" || stored === "false") return stored === "true" + if (drawioDark === "auto") return systemDark + return drawioDark === "dark" +} + +function read(): string | null { + try { + return localStorage.getItem(THEME_STORAGE_KEY) + } catch { + return null + } +} + +// The canvas components read the settings store's isDark (and the theme +// class, set before the first paint by index.html) +function apply(isDark: boolean) { + document.documentElement.classList.toggle("dark", isDark) + useSettingsStore.setState({ + hydrated: true, + isDark, + theme: isDark ? "dark" : "light", + }) +} + +/** Take the theme the inline script chose, and follow the system while + * nothing else decides */ +export function initTheme(drawioDark: ShellConfig["drawioDark"]) { + const query = window.matchMedia("(prefers-color-scheme: dark)") + apply(isDarkPreferred(read(), drawioDark, query.matches)) + query.addEventListener("change", (event) => { + apply(isDarkPreferred(read(), drawioDark, event.matches)) + }) +} + +/** The user's choice, kept for the next visit */ +export function setDark(isDark: boolean) { + try { + localStorage.setItem(THEME_STORAGE_KEY, String(isDark)) + } catch { + // Storage can be unavailable (private mode, quota) + } + apply(isDark) +} diff --git a/packages/mcp-server/shell/tsconfig.json b/packages/mcp-server/shell/tsconfig.json new file mode 100644 index 00000000..dcf2cc39 --- /dev/null +++ b/packages/mcp-server/shell/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "incremental": false, + "plugins": [] + }, + "include": ["./**/*.ts", "./**/*.tsx"] +} diff --git a/tests/unit/canvas-import-boundary.test.ts b/tests/unit/canvas-import-boundary.test.ts index 2c3be7cd..61fc7e5b 100644 --- a/tests/unit/canvas-import-boundary.test.ts +++ b/tests/unit/canvas-import-boundary.test.ts @@ -21,6 +21,8 @@ const SCANNED = [ "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 = [ @@ -92,6 +94,7 @@ describe("canvas import boundary", () => { 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) => { diff --git a/tests/unit/mcp-shell-config.test.ts b/tests/unit/mcp-shell-config.test.ts new file mode 100644 index 00000000..b2d90977 --- /dev/null +++ b/tests/unit/mcp-shell-config.test.ts @@ -0,0 +1,115 @@ +/** + * 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 { 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 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([ + "toggleTheme", + "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"') + }) +})