mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
feat(mcp-server): serve the canvas shell at /shell/ behind DRAWIO_PREVIEW_UI
GET /shell/ fills the page template's {{CONFIG_JSON}} with the session,
the API token, where draw.io comes from and the host's editor settings,
with the classic page's security headers; /shell/<file> serves the built
files like the draw.io copy. start_session opens the shell when
DRAWIO_PREVIEW_UI=shell; the classic page stays the default.
This commit is contained in:
@@ -188,6 +188,7 @@ To give the AI your own drawing rules, write them in `~/.next-ai-drawio/instruct
|
||||
| `DRAWIO_LANG` | unset | Language of the draw.io editor. Unset, draw.io chooses: the browser language on `embed.diagrams.net`, English on a self-hosted draw.io until the user picks one under **Extras > Language**. A code such as `en`, `zh`, `zh-tw`, `ja` or `de` fixes it and hides that submenu. |
|
||||
| `DRAWIO_UI` | unset | draw.io theme. Unset, the user picks one under **Extras > Theme** and draw.io remembers it. `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple` fixes the theme and hides that menu. |
|
||||
| `DRAWIO_DARK` | `auto` | Dark mode of the draw.io editor: `auto` follows the system, `1` forces dark, `0` forces light. The page header keeps following the system. |
|
||||
| `DRAWIO_PREVIEW_UI` | `classic` | The preview page `start_session` opens. `shell` opens the new canvas page built from the web app's canvas (in progress: it does not sync with the server yet). |
|
||||
| `DRAWIO_AUTO_SCREENSHOT` | unset | Set to `true` to attach a screenshot to every `create_new_diagram` and `edit_diagram` result, so the AI checks each drawing. Costs 2 to 10 s per call; the preview tab must be open and in front. A call can still pass `screenshot: false`. |
|
||||
| `DEBUG` | unset | Set to `true` to log debug messages to stderr. |
|
||||
|
||||
|
||||
@@ -88,6 +88,44 @@ export function setDrawioDir(dir: string | null): void {
|
||||
drawioVersion = readDrawioVersion(dir)
|
||||
}
|
||||
|
||||
// The canvas shell (the web app's canvas without the chat), built by
|
||||
// scripts/build-shell.mjs into dist/shell
|
||||
let shellDir: string | null =
|
||||
[join(HERE, "shell"), join(HERE, "../dist/shell")].find((dir) =>
|
||||
existsSync(join(dir, "index.html")),
|
||||
) ?? null
|
||||
|
||||
/** For tests: serve the shell from this directory (null: not built) */
|
||||
export function setShellDir(dir: string | null): void {
|
||||
shellDir = dir
|
||||
}
|
||||
|
||||
// This package's version, stamped into the shell files' ETags (see
|
||||
// readDrawioVersion for why)
|
||||
const PACKAGE_VERSION: string = JSON.parse(
|
||||
readFileSync(join(HERE, "../package.json"), "utf8"),
|
||||
).version
|
||||
|
||||
/**
|
||||
* The preview page start_session opens: the classic page (src/preview), or
|
||||
* the canvas shell with DRAWIO_PREVIEW_UI=shell. The shell becomes the
|
||||
* default once it does everything the classic page does.
|
||||
*/
|
||||
export type PreviewUi = "classic" | "shell"
|
||||
export const PREVIEW_UI: PreviewUi =
|
||||
(process.env.DRAWIO_PREVIEW_UI ?? "").toLowerCase() === "shell"
|
||||
? "shell"
|
||||
: "classic"
|
||||
|
||||
export function previewUrl(
|
||||
port: number,
|
||||
sessionId: string,
|
||||
ui: PreviewUi = PREVIEW_UI,
|
||||
): string {
|
||||
const path = ui === "shell" ? "/shell/" : ""
|
||||
return `http://localhost:${port}${path}?mcp=${sessionId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The draw.io version the fetch script stamps into the copy. It is part of
|
||||
* every file's ETag: an install that keeps the archive's dates (npm does
|
||||
@@ -495,7 +533,28 @@ function routeRequest(
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/drawio/")) {
|
||||
serveDrawioFile(req, res, url.pathname.slice("/drawio/".length))
|
||||
serveStaticFile(
|
||||
req,
|
||||
res,
|
||||
drawioDir,
|
||||
url.pathname.slice("/drawio/".length),
|
||||
drawioVersion,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/shell") {
|
||||
res.writeHead(302, { Location: `/shell/${url.search}` })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (url.pathname.startsWith("/shell/")) {
|
||||
const rest = url.pathname.slice("/shell/".length)
|
||||
if (rest === "" || rest === "index.html") {
|
||||
servePage(req, res, url, "/shell/", getShellPage)
|
||||
} else {
|
||||
serveStaticFile(req, res, shellDir, rest, PACKAGE_VERSION)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -509,34 +568,7 @@ function routeRequest(
|
||||
}
|
||||
|
||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||
const sessionId = url.searchParams.get("mcp") || ""
|
||||
if (sessionId && !isValidSessionId(sessionId)) {
|
||||
res.writeHead(400)
|
||||
res.end("Invalid session id")
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-redirect to most recent session if no sessionId provided
|
||||
if (!sessionId) {
|
||||
const recentSessionId = getMostRecentSessionId()
|
||||
if (recentSessionId) {
|
||||
res.writeHead(302, {
|
||||
Location: `/?mcp=${encodeURIComponent(recentSessionId)}`,
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ensureSessionStateInitialized(sessionId)
|
||||
|
||||
// The page holds this process's token: never served from a cache
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
...HTML_SECURITY_HEADERS,
|
||||
})
|
||||
res.end(getHtmlPage(sessionId))
|
||||
servePage(req, res, url, "/", getHtmlPage)
|
||||
} else if (url.pathname === "/api/state") {
|
||||
handleStateApi(req, res, url)
|
||||
} else if (url.pathname === "/api/history") {
|
||||
@@ -551,6 +583,60 @@ function routeRequest(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A preview page (the classic one at "/", the shell at "/shell/") for the
|
||||
* session in ?mcp=<id>; without one, the most recent session's.
|
||||
*/
|
||||
function servePage(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
url: URL,
|
||||
pagePath: string,
|
||||
render: (sessionId: string) => string | null,
|
||||
): void {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.writeHead(405)
|
||||
res.end("Method Not Allowed")
|
||||
return
|
||||
}
|
||||
const sessionId = url.searchParams.get("mcp") || ""
|
||||
if (sessionId && !isValidSessionId(sessionId)) {
|
||||
res.writeHead(400)
|
||||
res.end("Invalid session id")
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-redirect to most recent session if no sessionId provided
|
||||
if (!sessionId) {
|
||||
const recentSessionId = getMostRecentSessionId()
|
||||
if (recentSessionId) {
|
||||
res.writeHead(302, {
|
||||
Location: `${pagePath}?mcp=${encodeURIComponent(recentSessionId)}`,
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const html = render(sessionId)
|
||||
if (html === null) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" })
|
||||
res.end(
|
||||
"The canvas shell is not built (dist/shell is missing); run npm run build in packages/mcp-server",
|
||||
)
|
||||
return
|
||||
}
|
||||
ensureSessionStateInitialized(sessionId)
|
||||
|
||||
// The page holds this process's token: never served from a cache
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
...HTML_SECURITY_HEADERS,
|
||||
})
|
||||
res.end(req.method === "HEAD" ? undefined : html)
|
||||
}
|
||||
|
||||
function handleStateApi(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
@@ -899,11 +985,16 @@ const MIME_TYPES: Record<string, string> = {
|
||||
".wasm": "application/wasm",
|
||||
}
|
||||
|
||||
/** GET /drawio/<path>: a file of the bundled draw.io copy */
|
||||
function serveDrawioFile(
|
||||
/**
|
||||
* GET /drawio/<path> or /shell/<path>: a file of the bundled draw.io copy or
|
||||
* of the built shell. `stamp` names the files' version in their ETags.
|
||||
*/
|
||||
function serveStaticFile(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
dir: string | null,
|
||||
rawPath: string,
|
||||
stamp: string,
|
||||
): void {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.writeHead(405)
|
||||
@@ -921,13 +1012,13 @@ function serveDrawioFile(
|
||||
// One normalized path inside the directory; the war's server-side parts
|
||||
// are never served, whatever was extracted
|
||||
const normalized = posix.normalize(rel)
|
||||
const file = drawioDir ? resolve(drawioDir, normalized) : null
|
||||
const file = dir ? resolve(dir, normalized) : null
|
||||
if (
|
||||
!drawioDir ||
|
||||
!dir ||
|
||||
!file ||
|
||||
/[\\\0]/.test(rel) ||
|
||||
/(^|\/)(WEB-INF|META-INF)(\/|$)/i.test(normalized) ||
|
||||
!file.startsWith(drawioDir + sep)
|
||||
!file.startsWith(dir + sep)
|
||||
) {
|
||||
res.writeHead(404)
|
||||
res.end("Not Found")
|
||||
@@ -939,7 +1030,7 @@ function serveDrawioFile(
|
||||
const stat = statSync(file)
|
||||
if (!stat.isFile()) throw new Error("not a file")
|
||||
size = stat.size
|
||||
etag = `"${drawioVersion}-${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`
|
||||
etag = `"${stamp}-${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`
|
||||
} catch {
|
||||
res.writeHead(404)
|
||||
res.end("Not Found")
|
||||
@@ -997,37 +1088,92 @@ function loadPreviewTemplate(): string {
|
||||
return previewTemplate
|
||||
}
|
||||
|
||||
/** A JSON string literal that is safe inside a <script> element */
|
||||
const scriptJson = (value: string) =>
|
||||
/** JSON that is safe inside a <script> element ("<" escaped) */
|
||||
const scriptJson = (value: unknown) =>
|
||||
JSON.stringify(value).replace(/</g, "\\u003c")
|
||||
|
||||
/**
|
||||
* The configurable tail of the draw.io iframe query: dark mode, and the
|
||||
* language and theme when the host config fixes them (DRAWIO_DARK,
|
||||
* DRAWIO_LANG, DRAWIO_UI). lang and ui are left out unless set, because
|
||||
* draw.io hides its Extras > Language / Theme submenu once they are given.
|
||||
* The editor settings the host config fixes: theme (DRAWIO_UI), language
|
||||
* (DRAWIO_LANG) and dark mode (DRAWIO_DARK). ui and lang are "" unless set,
|
||||
* because draw.io hides its Extras > Language / Theme submenu once they
|
||||
* are given.
|
||||
*/
|
||||
function hostDrawioSettings(env: NodeJS.ProcessEnv): {
|
||||
ui: string
|
||||
lang: string
|
||||
dark: "dark" | "light" | "auto"
|
||||
} {
|
||||
const ui = (env.DRAWIO_UI ?? "").toLowerCase()
|
||||
const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
|
||||
const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
|
||||
return {
|
||||
ui: isDrawioTheme(ui) ? ui : "",
|
||||
lang: /^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang) ? lang : "",
|
||||
dark: ["1", "true", "dark"].includes(dark)
|
||||
? "dark"
|
||||
: ["0", "false", "light"].includes(dark)
|
||||
? "light"
|
||||
: // draw.io takes ui=dark as dark mode only when no dark
|
||||
// parameter is present, and the pages always send one
|
||||
!dark && ui === "dark"
|
||||
? "dark"
|
||||
: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The configurable tail of the classic page's draw.io iframe query: dark
|
||||
* mode, and the language and theme when the host config fixes them.
|
||||
*/
|
||||
export function drawioEmbedParams(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const settings = hostDrawioSettings(env)
|
||||
const params = new URLSearchParams()
|
||||
const ui = (env.DRAWIO_UI ?? "").toLowerCase()
|
||||
const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
|
||||
const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
|
||||
if (["1", "true", "dark"].includes(dark)) {
|
||||
params.set("dark", "1")
|
||||
} else if (["0", "false", "light"].includes(dark)) {
|
||||
params.set("dark", "0")
|
||||
} else {
|
||||
// draw.io takes ui=dark as dark mode only when no dark parameter is
|
||||
// present, and this page always sends one
|
||||
params.set("dark", !dark && ui === "dark" ? "1" : "auto")
|
||||
}
|
||||
if (/^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang)) params.set("lang", lang)
|
||||
if (isDrawioTheme(ui)) params.set("ui", ui)
|
||||
params.set(
|
||||
"dark",
|
||||
settings.dark === "auto"
|
||||
? "auto"
|
||||
: settings.dark === "dark"
|
||||
? "1"
|
||||
: "0",
|
||||
)
|
||||
if (settings.lang) params.set("lang", settings.lang)
|
||||
if (settings.ui) params.set("ui", settings.ui)
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* What the shell page gets as window.__MCP_CONFIG__ (shell/runtime-config.ts
|
||||
* reads it): the session, the API token, where draw.io comes from and the
|
||||
* host's editor settings.
|
||||
*/
|
||||
export function shellConfig(
|
||||
sessionId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Record<string, string> {
|
||||
const settings = hostDrawioSettings(env)
|
||||
return {
|
||||
sessionId,
|
||||
token: API_TOKEN,
|
||||
apiBase: "/api",
|
||||
drawioBaseUrl: drawioEditorUrl(),
|
||||
drawioUi: settings.ui,
|
||||
drawioLang: settings.lang,
|
||||
drawioDark: settings.dark,
|
||||
lang: env.DRAWIO_LANG ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
/** The shell page, or null when the shell is not built */
|
||||
function getShellPage(sessionId: string): string | null {
|
||||
if (!shellDir) return null
|
||||
const template = readFileSync(join(shellDir, "index.html"), "utf8")
|
||||
return template.replace("{{CONFIG_JSON}}", () =>
|
||||
scriptJson(shellConfig(sessionId)),
|
||||
)
|
||||
}
|
||||
|
||||
function getHtmlPage(sessionId: string): string {
|
||||
return (
|
||||
loadPreviewTemplate()
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
keepInHistory,
|
||||
onSessionRecreate,
|
||||
onStateChange,
|
||||
previewUrl,
|
||||
requestExport,
|
||||
requestSync,
|
||||
restoreHistoryEntry,
|
||||
@@ -382,7 +383,7 @@ registerWriteTool(
|
||||
}
|
||||
|
||||
// Open browser
|
||||
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
|
||||
const browserUrl = previewUrl(port, sessionId)
|
||||
await open(browserUrl)
|
||||
|
||||
// A saved diagram comes back from its file. lastSeenXml stays
|
||||
|
||||
@@ -27,11 +27,14 @@ import {
|
||||
keepInHistory,
|
||||
onSessionRecreate,
|
||||
onStateChange,
|
||||
previewUrl,
|
||||
requestExport,
|
||||
requestSync,
|
||||
restoreHistoryEntry,
|
||||
setDrawioDir,
|
||||
setShellDir,
|
||||
setState,
|
||||
shellConfig,
|
||||
shutdown,
|
||||
startHttpServer,
|
||||
waitForSync,
|
||||
@@ -41,6 +44,8 @@ let port = 0
|
||||
// A stand-in for the bundled draw.io copy (dist/drawio), with a server-side
|
||||
// part of the war that must never be served
|
||||
let drawioDir = ""
|
||||
// A stand-in for the built canvas shell (dist/shell)
|
||||
let shellDir = ""
|
||||
|
||||
beforeAll(async () => {
|
||||
// XML parsing, as the server installs it at startup
|
||||
@@ -53,12 +58,21 @@ beforeAll(async () => {
|
||||
writeFileSync(join(drawioDir, "js/app.min.js"), "// app")
|
||||
writeFileSync(join(drawioDir, "WEB-INF/web.xml"), "<web-app/>")
|
||||
setDrawioDir(drawioDir)
|
||||
shellDir = mkdtempSync(join(tmpdir(), "shell-static-"))
|
||||
writeFileSync(
|
||||
join(shellDir, "index.html"),
|
||||
"<html><script>window.__MCP_CONFIG__ = {{CONFIG_JSON}};</script></html>",
|
||||
)
|
||||
writeFileSync(join(shellDir, "shell.js"), "// shell")
|
||||
writeFileSync(join(shellDir, "shell.css"), "body{}")
|
||||
setShellDir(shellDir)
|
||||
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
shutdown()
|
||||
rmSync(drawioDir, { recursive: true, force: true })
|
||||
rmSync(shellDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
interface Response {
|
||||
@@ -705,6 +719,160 @@ describe("bundled draw.io files", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("canvas shell page", () => {
|
||||
/** The config the served page carries */
|
||||
const configOf = (body: string) => {
|
||||
const match = body.match(
|
||||
/window\.__MCP_CONFIG__ = (\{.*?\});<\/script>/s,
|
||||
)
|
||||
if (!match) throw new Error(`no config in ${body}`)
|
||||
return JSON.parse(match[1])
|
||||
}
|
||||
|
||||
it("writes the session, the token and where draw.io comes from into the page", async () => {
|
||||
const res = await request("/shell/?mcp=mcp-shell-page")
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers["content-type"]).toBe("text/html; charset=utf-8")
|
||||
expect(res.body).not.toContain("{{")
|
||||
expect(configOf(res.body)).toEqual({
|
||||
sessionId: "mcp-shell-page",
|
||||
token: getApiToken(),
|
||||
apiBase: "/api",
|
||||
drawioBaseUrl: "/drawio/index.html",
|
||||
drawioUi: "",
|
||||
drawioLang: "",
|
||||
drawioDark: "auto",
|
||||
lang: "",
|
||||
})
|
||||
// The page creates the session, as the classic page does
|
||||
expect(getState("mcp-shell-page")).toBeDefined()
|
||||
// The same page at its index.html name
|
||||
const index = await request("/shell/index.html?mcp=mcp-shell-page")
|
||||
expect(index.status).toBe(200)
|
||||
expect(configOf(index.body).sessionId).toBe("mcp-shell-page")
|
||||
})
|
||||
|
||||
it("passes the host's editor settings on, in the shell's own terms", () => {
|
||||
const config = shellConfig("mcp-s", {
|
||||
DRAWIO_UI: "Sketch",
|
||||
DRAWIO_LANG: "zh-Hant",
|
||||
DRAWIO_DARK: "1",
|
||||
})
|
||||
expect(config.drawioUi).toBe("sketch")
|
||||
expect(config.drawioLang).toBe("zh-tw")
|
||||
expect(config.drawioDark).toBe("dark")
|
||||
// The language as given, for the shell's own texts
|
||||
expect(config.lang).toBe("zh-Hant")
|
||||
expect(shellConfig("mcp-s", { DRAWIO_DARK: "light" }).drawioDark).toBe(
|
||||
"light",
|
||||
)
|
||||
expect(shellConfig("mcp-s", { DRAWIO_UI: "dark" }).drawioDark).toBe(
|
||||
"dark",
|
||||
)
|
||||
expect(shellConfig("mcp-s", { DRAWIO_UI: "neon" }).drawioUi).toBe("")
|
||||
})
|
||||
|
||||
it("names the external draw.io when there is no bundled copy", async () => {
|
||||
setDrawioDir(null)
|
||||
try {
|
||||
const res = await request("/shell/?mcp=mcp-shell-external")
|
||||
expect(configOf(res.body).drawioBaseUrl).toBe(
|
||||
"https://embed.diagrams.net/",
|
||||
)
|
||||
} finally {
|
||||
setDrawioDir(drawioDir)
|
||||
}
|
||||
})
|
||||
|
||||
it("escapes a value that could close the script element", async () => {
|
||||
const before = process.env.DRAWIO_LANG
|
||||
process.env.DRAWIO_LANG = "</script><script>alert(1)</script>"
|
||||
try {
|
||||
const res = await request("/shell/?mcp=mcp-shell-escape")
|
||||
expect(res.body).not.toContain("</script><script>alert")
|
||||
expect(res.body).toContain("\\u003c/script>")
|
||||
expect(configOf(res.body).lang).toBe(process.env.DRAWIO_LANG)
|
||||
} finally {
|
||||
if (before === undefined) delete process.env.DRAWIO_LANG
|
||||
else process.env.DRAWIO_LANG = before
|
||||
}
|
||||
})
|
||||
|
||||
it("sends the security headers, is never cached and needs no token", async () => {
|
||||
const res = await request("/shell/?mcp=mcp-shell-headers", {
|
||||
headers: { "x-drawio-token": "" },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers["content-security-policy"]).toBe(
|
||||
"frame-ancestors 'self'",
|
||||
)
|
||||
expect(res.headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(res.headers["cache-control"]).toBe("no-store")
|
||||
})
|
||||
|
||||
it("rejects a bad session id and goes to the most recent session without one", async () => {
|
||||
const bad = await request(
|
||||
`/shell/?mcp=${encodeURIComponent('";alert(1)//')}`,
|
||||
)
|
||||
expect(bad.status).toBe(400)
|
||||
setState("mcp-shell-recent", "<mxfile>recent</mxfile>")
|
||||
const res = await request("/shell/")
|
||||
expect(res.status).toBe(302)
|
||||
// (other tests of this file create sessions in the same millisecond)
|
||||
expect(res.headers.location).toMatch(/^\/shell\/\?mcp=mcp-/)
|
||||
// Without the trailing slash the assets would resolve elsewhere
|
||||
const slash = await request("/shell?mcp=mcp-shell-recent")
|
||||
expect(slash.status).toBe(302)
|
||||
expect(slash.headers.location).toBe("/shell/?mcp=mcp-shell-recent")
|
||||
})
|
||||
|
||||
it("serves the shell's files with their MIME types, without a token", async () => {
|
||||
const js = await request("/shell/shell.js", {
|
||||
headers: { "x-drawio-token": "" },
|
||||
})
|
||||
expect(js.status).toBe(200)
|
||||
expect(js.body).toBe("// shell")
|
||||
expect(js.headers["content-type"]).toBe(
|
||||
"text/javascript; charset=utf-8",
|
||||
)
|
||||
expect(js.headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(js.headers["cache-control"]).toBe("no-cache")
|
||||
expect(js.headers.etag).toMatch(/^"\d+\.\d+\.\d+-[0-9a-f]+-[0-9a-f]+"$/)
|
||||
const css = await request("/shell/shell.css")
|
||||
expect(css.headers["content-type"]).toBe("text/css; charset=utf-8")
|
||||
for (const path of [
|
||||
"/shell/../package.json",
|
||||
"/shell/%2e%2e/package.json",
|
||||
"/shell/missing.js",
|
||||
]) {
|
||||
const res = await request(path)
|
||||
expect(res.status, path).toBe(404)
|
||||
expect(res.body).not.toContain('"name"')
|
||||
}
|
||||
})
|
||||
|
||||
it("says so when the shell is not built", async () => {
|
||||
setShellDir(null)
|
||||
try {
|
||||
const page = await request("/shell/?mcp=mcp-shell-unbuilt")
|
||||
expect(page.status).toBe(404)
|
||||
expect(page.body).toContain("npm run build")
|
||||
expect((await request("/shell/shell.js")).status).toBe(404)
|
||||
} finally {
|
||||
setShellDir(shellDir)
|
||||
}
|
||||
})
|
||||
|
||||
it("is what start_session opens with DRAWIO_PREVIEW_UI=shell", () => {
|
||||
expect(previewUrl(6002, "mcp-x", "shell")).toBe(
|
||||
"http://localhost:6002/shell/?mcp=mcp-x",
|
||||
)
|
||||
expect(previewUrl(6002, "mcp-x", "classic")).toBe(
|
||||
"http://localhost:6002?mcp=mcp-x",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("draw.io embed parameters from the host config", () => {
|
||||
it("follows the system dark mode and fixes nothing else by default", () => {
|
||||
expect(drawioEmbedParams({})).toBe("dark=auto")
|
||||
|
||||
Reference in New Issue
Block a user