2026-10-05 22:39:32 +09:00
|
|
|
/**
|
|
|
|
|
* Tests for the embedded HTTP server (browser bridge).
|
|
|
|
|
*
|
|
|
|
|
* The server runs in-process on a random high port (never 6002, which is
|
|
|
|
|
* also the default port of the Next.js dev server). Requests go through
|
|
|
|
|
* node:http so tests can set raw paths and Host/Origin headers.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-10-11 12:42:54 +09:00
|
|
|
import {
|
|
|
|
|
mkdirSync,
|
|
|
|
|
mkdtempSync,
|
|
|
|
|
rmSync,
|
|
|
|
|
utimesSync,
|
|
|
|
|
writeFileSync,
|
|
|
|
|
} from "node:fs"
|
2026-10-05 22:39:32 +09:00
|
|
|
import http from "node:http"
|
2026-10-11 11:17:32 +09:00
|
|
|
import { tmpdir } from "node:os"
|
|
|
|
|
import { join } from "node:path"
|
2026-10-05 22:39:32 +09:00
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
|
|
|
|
import { installDomPolyfill } from "../src/dom.ts"
|
|
|
|
|
import { addHistory, getHistory } from "../src/history.ts"
|
|
|
|
|
import {
|
2026-10-11 20:40:05 +09:00
|
|
|
drawioEmbedParams,
|
2026-10-11 11:17:32 +09:00
|
|
|
getApiToken,
|
2026-10-05 22:39:32 +09:00
|
|
|
getState,
|
2026-10-11 11:17:32 +09:00
|
|
|
isSameOriginDrawio,
|
2026-10-05 22:39:32 +09:00
|
|
|
keepInHistory,
|
|
|
|
|
onSessionRecreate,
|
2026-10-11 20:23:23 +09:00
|
|
|
onStateChange,
|
2026-10-11 13:04:44 +09:00
|
|
|
previewUrl,
|
2026-10-05 22:39:32 +09:00
|
|
|
requestExport,
|
|
|
|
|
requestSync,
|
2026-10-11 20:23:23 +09:00
|
|
|
restoreHistoryEntry,
|
2026-10-11 11:17:32 +09:00
|
|
|
setDrawioDir,
|
2026-10-11 13:04:44 +09:00
|
|
|
setShellDir,
|
2026-10-05 22:39:32 +09:00
|
|
|
setState,
|
2026-10-11 13:04:44 +09:00
|
|
|
shellConfig,
|
2026-10-05 22:39:32 +09:00
|
|
|
shutdown,
|
|
|
|
|
startHttpServer,
|
|
|
|
|
waitForSync,
|
|
|
|
|
} from "../src/http-server.ts"
|
|
|
|
|
|
|
|
|
|
let port = 0
|
2026-10-11 11:17:32 +09:00
|
|
|
// 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 = ""
|
2026-10-11 13:04:44 +09:00
|
|
|
// A stand-in for the built canvas shell (dist/shell)
|
|
|
|
|
let shellDir = ""
|
2026-10-05 22:39:32 +09:00
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
// XML parsing, as the server installs it at startup
|
|
|
|
|
installDomPolyfill()
|
2026-10-11 11:17:32 +09:00
|
|
|
drawioDir = mkdtempSync(join(tmpdir(), "drawio-static-"))
|
|
|
|
|
mkdirSync(join(drawioDir, "js"))
|
|
|
|
|
mkdirSync(join(drawioDir, "WEB-INF"))
|
|
|
|
|
writeFileSync(join(drawioDir, "index.html"), "<html>draw.io</html>")
|
2026-10-11 12:42:54 +09:00
|
|
|
writeFileSync(join(drawioDir, ".version"), "v1\n")
|
2026-10-11 11:17:32 +09:00
|
|
|
writeFileSync(join(drawioDir, "js/app.min.js"), "// app")
|
|
|
|
|
writeFileSync(join(drawioDir, "WEB-INF/web.xml"), "<web-app/>")
|
|
|
|
|
setDrawioDir(drawioDir)
|
2026-10-11 13:04:44 +09:00
|
|
|
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)
|
2026-10-05 22:39:32 +09:00
|
|
|
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterAll(() => {
|
|
|
|
|
shutdown()
|
2026-10-11 11:17:32 +09:00
|
|
|
rmSync(drawioDir, { recursive: true, force: true })
|
2026-10-11 13:04:44 +09:00
|
|
|
rmSync(shellDir, { recursive: true, force: true })
|
2026-10-05 22:39:32 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
interface Response {
|
|
|
|
|
status: number
|
|
|
|
|
headers: http.IncomingHttpHeaders
|
|
|
|
|
body: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 11:17:32 +09:00
|
|
|
/**
|
|
|
|
|
* Send a request as the preview page does (same Host, the API token);
|
|
|
|
|
* `body` may be split into several writes.
|
|
|
|
|
*/
|
2026-10-05 22:39:32 +09:00
|
|
|
function request(
|
|
|
|
|
path: string,
|
|
|
|
|
opts: {
|
|
|
|
|
method?: string
|
|
|
|
|
headers?: Record<string, string>
|
|
|
|
|
body?: Buffer[]
|
|
|
|
|
} = {},
|
|
|
|
|
): Promise<Response> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const req = http.request(
|
|
|
|
|
{
|
|
|
|
|
host: "127.0.0.1",
|
|
|
|
|
port,
|
|
|
|
|
path,
|
|
|
|
|
method: opts.method ?? "GET",
|
2026-10-11 11:17:32 +09:00
|
|
|
headers: {
|
|
|
|
|
host: `localhost:${port}`,
|
|
|
|
|
"x-drawio-token": getApiToken(),
|
|
|
|
|
...opts.headers,
|
|
|
|
|
},
|
2026-10-05 22:39:32 +09:00
|
|
|
},
|
|
|
|
|
(res) => {
|
|
|
|
|
const chunks: Buffer[] = []
|
|
|
|
|
res.on("data", (c: Buffer) => chunks.push(c))
|
|
|
|
|
res.on("end", () =>
|
|
|
|
|
resolve({
|
|
|
|
|
status: res.statusCode ?? 0,
|
|
|
|
|
headers: res.headers,
|
|
|
|
|
body: Buffer.concat(chunks).toString("utf8"),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
req.on("error", reject)
|
|
|
|
|
const parts = opts.body ?? []
|
|
|
|
|
// Pause between parts so the server reads them as separate chunks
|
|
|
|
|
const writeNext = (i: number) => {
|
|
|
|
|
if (i >= parts.length) return req.end()
|
|
|
|
|
req.write(parts[i])
|
|
|
|
|
setTimeout(() => writeNext(i + 1), 30)
|
|
|
|
|
}
|
|
|
|
|
writeNext(0)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const postJson = (path: string, data: unknown, headers = {}) =>
|
|
|
|
|
request(path, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "content-type": "application/json", ...headers },
|
|
|
|
|
body: [Buffer.from(JSON.stringify(data))],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("session id in the page URL", () => {
|
|
|
|
|
it("rejects a session id that could inject script", async () => {
|
|
|
|
|
const res = await request(`/?mcp=${encodeURIComponent('";alert(1)//')}`)
|
|
|
|
|
expect(res.status).toBe(400)
|
|
|
|
|
expect(res.body).not.toContain("alert")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("writes a valid session id into the page script as a JSON string", async () => {
|
|
|
|
|
const res = await request("/?mcp=mcp-test-page")
|
|
|
|
|
expect(res.status).toBe(200)
|
|
|
|
|
expect(res.body).toContain('const sessionId = "mcp-test-page";')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("requests that used to crash the process", () => {
|
|
|
|
|
it("answers 400 for a path that is not a valid URL", async () => {
|
|
|
|
|
const res = await request("//")
|
|
|
|
|
expect(res.status).toBe(400)
|
|
|
|
|
// The server is still alive
|
|
|
|
|
expect((await request("/api/state?sessionId=mcp-alive")).status).toBe(
|
|
|
|
|
200,
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("never creates sessions with ids unsafe for the Location header", async () => {
|
|
|
|
|
const badId = "mcp-中"
|
|
|
|
|
await request(`/api/state?sessionId=${encodeURIComponent(badId)}`)
|
|
|
|
|
expect(getState(badId)).toBeUndefined()
|
|
|
|
|
const post = await postJson("/api/state", {
|
|
|
|
|
sessionId: badId,
|
|
|
|
|
xml: "<mxfile/>",
|
|
|
|
|
})
|
|
|
|
|
expect(post.status).toBe(400)
|
|
|
|
|
expect(getState(badId)).toBeUndefined()
|
|
|
|
|
|
|
|
|
|
const res = await request("/")
|
|
|
|
|
expect([200, 302]).toContain(res.status)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("request origin checks", () => {
|
|
|
|
|
it("refuses a foreign Host header (DNS rebinding)", async () => {
|
|
|
|
|
const res = await request("/api/state?sessionId=mcp-alive", {
|
|
|
|
|
headers: { host: `evil.example:${port}` },
|
|
|
|
|
})
|
|
|
|
|
expect(res.status).toBe(403)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("refuses writes from another website", async () => {
|
|
|
|
|
const res = await postJson(
|
|
|
|
|
"/api/state",
|
|
|
|
|
{ sessionId: "mcp-csrf", xml: "<mxfile/>" },
|
|
|
|
|
{ origin: "https://evil.example" },
|
|
|
|
|
)
|
|
|
|
|
expect(res.status).toBe(403)
|
|
|
|
|
expect(getState("mcp-csrf")).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("accepts writes from the page itself", async () => {
|
|
|
|
|
const res = await postJson(
|
|
|
|
|
"/api/state",
|
|
|
|
|
{ sessionId: "mcp-same-origin", xml: "<mxfile/>" },
|
|
|
|
|
{ origin: `http://localhost:${port}` },
|
|
|
|
|
)
|
|
|
|
|
expect(res.status).toBe(200)
|
|
|
|
|
// Opened as 127.0.0.1, or through a forwarded port: Origin and
|
|
|
|
|
// Host name the same host
|
|
|
|
|
for (const host of [`127.0.0.1:${port}`, "localhost:7000"]) {
|
|
|
|
|
const page = await postJson(
|
|
|
|
|
"/api/state",
|
|
|
|
|
{ sessionId: "mcp-same-origin", xml: "<mxfile/>" },
|
|
|
|
|
{ origin: `http://${host}`, host },
|
|
|
|
|
)
|
|
|
|
|
expect(page.status).toBe(200)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("refuses writes from a page on another localhost port", async () => {
|
|
|
|
|
// A plain text POST needs no CORS preflight, so the server must
|
|
|
|
|
// refuse it itself
|
|
|
|
|
setState("mcp-other-port", "<mxfile>kept</mxfile>")
|
|
|
|
|
for (const path of ["/api/state", "/api/history-svg"]) {
|
|
|
|
|
const res = await postJson(
|
|
|
|
|
path,
|
|
|
|
|
{
|
|
|
|
|
sessionId: "mcp-other-port",
|
|
|
|
|
xml: "<mxfile>replaced</mxfile>",
|
|
|
|
|
svg: "x",
|
|
|
|
|
},
|
|
|
|
|
{ origin: "http://localhost:3000" },
|
|
|
|
|
)
|
|
|
|
|
expect(res.status).toBe(403)
|
|
|
|
|
}
|
|
|
|
|
expect(getState("mcp-other-port")?.xml).toBe("<mxfile>kept</mxfile>")
|
|
|
|
|
expect(getState("mcp-other-port")?.svg).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("POST /api/state", () => {
|
|
|
|
|
it("refuses a push without xml and keeps the diagram", async () => {
|
|
|
|
|
setState("mcp-no-xml", "<mxfile>kept</mxfile>")
|
|
|
|
|
const res = await postJson("/api/state", {
|
|
|
|
|
sessionId: "mcp-no-xml",
|
|
|
|
|
baseVersion: 99,
|
|
|
|
|
})
|
|
|
|
|
expect(res.status).toBe(400)
|
|
|
|
|
expect(getState("mcp-no-xml")?.xml).toBe("<mxfile>kept</mxfile>")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("decodes UTF-8 characters split across body chunks", async () => {
|
|
|
|
|
const xml = `<mxfile>${"数据".repeat(30000)}</mxfile>`
|
|
|
|
|
const body = Buffer.from(JSON.stringify({ sessionId: "mcp-utf8", xml }))
|
|
|
|
|
// Cut inside a 3-byte character
|
|
|
|
|
const cut = body.indexOf(Buffer.from("数")) + 1
|
|
|
|
|
const res = await request("/api/state", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "content-type": "application/json" },
|
|
|
|
|
body: [body.subarray(0, cut), body.subarray(cut)],
|
|
|
|
|
})
|
|
|
|
|
expect(res.status).toBe(200)
|
|
|
|
|
expect(getState("mcp-utf8")?.xml).toBe(xml)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("rejects a browser push based on a version older than an AI write", async () => {
|
|
|
|
|
const id = "mcp-conflict"
|
|
|
|
|
setState(id, "<mxfile>user v1</mxfile>", undefined, true)
|
|
|
|
|
const aiVersion = setState(id, "<mxfile>AI edit</mxfile>")
|
|
|
|
|
|
|
|
|
|
const stale = await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
xml: "<mxfile>user edit on old version</mxfile>",
|
|
|
|
|
baseVersion: aiVersion - 1,
|
|
|
|
|
})
|
|
|
|
|
expect(stale.status).toBe(409)
|
|
|
|
|
expect(getState(id)?.xml).toBe("<mxfile>AI edit</mxfile>")
|
|
|
|
|
|
|
|
|
|
// Pushes based on the AI version are accepted, including a second
|
|
|
|
|
// push sent before the first one's response updated the browser
|
|
|
|
|
for (const xml of ["<mxfile>a</mxfile>", "<mxfile>b</mxfile>"]) {
|
|
|
|
|
const ok = await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
xml,
|
|
|
|
|
baseVersion: aiVersion,
|
|
|
|
|
})
|
|
|
|
|
expect(ok.status).toBe(200)
|
|
|
|
|
expect(getState(id)?.xml).toBe(xml)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps a rejected user edit in history", async () => {
|
|
|
|
|
const id = "mcp-conflict-history"
|
|
|
|
|
setState(id, "<mxfile>user v1</mxfile>", undefined, true)
|
|
|
|
|
const aiVersion = setState(id, "<mxfile>AI edit</mxfile>")
|
|
|
|
|
const before = getHistory(id).length
|
|
|
|
|
|
|
|
|
|
const stale = await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
xml: "<mxfile>lost user edit</mxfile>",
|
|
|
|
|
baseVersion: aiVersion - 1,
|
|
|
|
|
})
|
|
|
|
|
expect(stale.status).toBe(409)
|
|
|
|
|
expect(JSON.parse(stale.body).savedToHistory).toBe(true)
|
|
|
|
|
const history = getHistory(id)
|
|
|
|
|
expect(history).toHaveLength(before + 1)
|
|
|
|
|
expect(history.at(-1)?.xml).toBe("<mxfile>lost user edit</mxfile>")
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-11 20:23:23 +09:00
|
|
|
it("asks for a save when a lost user edit is kept in history", async () => {
|
|
|
|
|
const id = "mcp-conflict-save"
|
|
|
|
|
setState(id, "<mxfile>user v1</mxfile>", undefined, true)
|
|
|
|
|
const aiVersion = setState(id, "<mxfile>AI edit</mxfile>")
|
|
|
|
|
const saves: string[] = []
|
|
|
|
|
onStateChange((sessionId, xml) => saves.push(`${sessionId} ${xml}`))
|
|
|
|
|
try {
|
|
|
|
|
await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
xml: "<mxfile>lost user edit</mxfile>",
|
|
|
|
|
baseVersion: aiVersion - 1,
|
|
|
|
|
})
|
|
|
|
|
} finally {
|
|
|
|
|
onStateChange(() => {})
|
|
|
|
|
}
|
|
|
|
|
// The canvas stays the AI write; the save takes the new History
|
|
|
|
|
expect(saves).toEqual([`${id} <mxfile>AI edit</mxfile>`])
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-05 22:39:32 +09:00
|
|
|
it("ends a pending sync when the sync reply is older than an AI write", async () => {
|
|
|
|
|
const id = "mcp-stale-sync"
|
|
|
|
|
setState(id, "<mxfile>before</mxfile>", undefined, true)
|
|
|
|
|
const aiVersion = setState(id, "<mxfile>AI edit</mxfile>")
|
|
|
|
|
requestSync(id)
|
|
|
|
|
const before = getHistory(id).length
|
|
|
|
|
|
|
|
|
|
// The browser exported its old diagram, then loaded the AI write
|
|
|
|
|
const stale = await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
xml: "<mxfile>before</mxfile>",
|
|
|
|
|
baseVersion: aiVersion - 1,
|
|
|
|
|
source: "sync",
|
|
|
|
|
})
|
|
|
|
|
expect(stale.status).toBe(409)
|
|
|
|
|
expect(JSON.parse(stale.body).savedToHistory).toBe(false)
|
|
|
|
|
expect(getState(id)?.xml).toBe("<mxfile>AI edit</mxfile>")
|
|
|
|
|
expect(getState(id)?.syncRequested).toBeUndefined()
|
|
|
|
|
expect(getHistory(id)).toHaveLength(before)
|
|
|
|
|
expect(await waitForSync(id, 200)).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("ignores a sync reply older than a user edit saved meanwhile", async () => {
|
|
|
|
|
const id = "mcp-late-sync"
|
|
|
|
|
const version = setState(id, "<mxfile>A</mxfile>", undefined, true)
|
|
|
|
|
requestSync(id)
|
|
|
|
|
// The user's edit is saved before the sync reply arrives
|
|
|
|
|
const edit = await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
xml: "<mxfile>B</mxfile>",
|
|
|
|
|
baseVersion: version,
|
|
|
|
|
})
|
|
|
|
|
expect(edit.status).toBe(200)
|
|
|
|
|
const late = await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
xml: "<mxfile>A</mxfile>",
|
|
|
|
|
baseVersion: version,
|
|
|
|
|
source: "sync",
|
|
|
|
|
})
|
|
|
|
|
expect(late.status).toBe(409)
|
|
|
|
|
expect(getState(id)?.xml).toBe("<mxfile>B</mxfile>")
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("export requests", () => {
|
|
|
|
|
it("hands draw.io export options to the page and clears them after", async () => {
|
|
|
|
|
const id = "mcp-export-options"
|
|
|
|
|
setState(id, "<mxfile>x</mxfile>")
|
|
|
|
|
requestExport(id, "png", undefined, { width: 1000, pageId: "p2" })
|
|
|
|
|
const poll = JSON.parse(
|
|
|
|
|
(await request(`/api/state?sessionId=${id}`)).body,
|
|
|
|
|
)
|
|
|
|
|
expect(poll.exportFormat).toBe("png")
|
|
|
|
|
expect(poll.exportOptions).toEqual({ width: 1000, pageId: "p2" })
|
|
|
|
|
|
|
|
|
|
await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
exportData: "data:image/png;base64,AAAA",
|
|
|
|
|
exportId: poll.exportId,
|
|
|
|
|
})
|
|
|
|
|
expect(getState(id)?.exportOptions).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("ignores a late result of an export that already timed out", async () => {
|
|
|
|
|
const id = "mcp-export-late"
|
|
|
|
|
setState(id, "<mxfile>x</mxfile>")
|
|
|
|
|
requestExport(id, "png")
|
|
|
|
|
const first = JSON.parse(
|
|
|
|
|
(await request(`/api/state?sessionId=${id}`)).body,
|
|
|
|
|
)
|
|
|
|
|
// The server gave up on the first export and asked for the next
|
|
|
|
|
requestExport(id, "svg")
|
|
|
|
|
await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
exportData: "data:image/png;base64,LATE",
|
|
|
|
|
exportId: first.exportId,
|
|
|
|
|
})
|
|
|
|
|
expect(getState(id)?.exportData).toBeUndefined()
|
|
|
|
|
expect(getState(id)?.exportFormat).toBe("svg")
|
|
|
|
|
})
|
2026-10-11 12:42:54 +09:00
|
|
|
|
|
|
|
|
it("names exports at random, so a result meant for another process is not taken", async () => {
|
|
|
|
|
const id = "mcp-export-random-id"
|
|
|
|
|
setState(id, "<mxfile>x</mxfile>")
|
|
|
|
|
requestExport(id, "svg")
|
|
|
|
|
const poll = JSON.parse(
|
|
|
|
|
(await request(`/api/state?sessionId=${id}`)).body,
|
|
|
|
|
)
|
|
|
|
|
expect(poll.exportId).toMatch(/^[0-9a-f-]{36}$/)
|
|
|
|
|
// The page retries a result refused with 403 against the process
|
|
|
|
|
// that took over the port; a per-process counter would restart at 1
|
|
|
|
|
await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
exportData: "<svg>OLD</svg>",
|
|
|
|
|
exportId: 1,
|
|
|
|
|
})
|
|
|
|
|
expect(getState(id)?.exportData).toBeUndefined()
|
|
|
|
|
expect(getState(id)?.exportFormat).toBe("svg")
|
|
|
|
|
})
|
2026-10-05 22:39:32 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("a session state recreated after it was lost", () => {
|
|
|
|
|
const SAVED = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="saved" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
|
|
|
|
const getJson = async (id: string) =>
|
|
|
|
|
JSON.parse((await request(`/api/state?sessionId=${id}`)).body)
|
|
|
|
|
|
|
|
|
|
it("names each state, and says when it was made blank", async () => {
|
|
|
|
|
const first = await getJson("mcp-sid-blank")
|
|
|
|
|
expect(first.stateId).toMatch(/^[0-9a-f-]{36}$/)
|
|
|
|
|
expect(first.blank).toBe(true)
|
|
|
|
|
setState("mcp-sid-blank", "<mxfile>AI write</mxfile>")
|
|
|
|
|
const after = await getJson("mcp-sid-blank")
|
|
|
|
|
// Same state, no longer blank
|
|
|
|
|
expect(after.stateId).toBe(first.stateId)
|
|
|
|
|
expect(after.blank).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("refuses a push made for another state, also before any poll", async () => {
|
|
|
|
|
// The MCP process restarted; the tab's push comes before its poll
|
|
|
|
|
onSessionRecreate((id) => (id === "mcp-sid-restart" ? SAVED : null))
|
|
|
|
|
try {
|
|
|
|
|
for (const stateId of ["from-before", null]) {
|
|
|
|
|
const res = await postJson("/api/state", {
|
|
|
|
|
sessionId: "mcp-sid-restart",
|
|
|
|
|
xml: "<mxfile>tab's old copy</mxfile>",
|
|
|
|
|
baseVersion: 7,
|
|
|
|
|
stateId,
|
|
|
|
|
})
|
|
|
|
|
expect(res.status).toBe(409)
|
|
|
|
|
expect(JSON.parse(res.body).stateChanged).toBe(true)
|
|
|
|
|
// The saved file was recovered first and is kept
|
|
|
|
|
expect(getState("mcp-sid-restart")?.xml).toBe(SAVED)
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
onSessionRecreate(() => null)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("accepts a push for the current state", async () => {
|
|
|
|
|
const { stateId, version } = await getJson("mcp-sid-ok")
|
|
|
|
|
const res = await postJson("/api/state", {
|
|
|
|
|
sessionId: "mcp-sid-ok",
|
|
|
|
|
xml: "<mxfile>user edit</mxfile>",
|
|
|
|
|
baseVersion: version,
|
|
|
|
|
stateId,
|
|
|
|
|
})
|
|
|
|
|
expect(res.status).toBe(200)
|
|
|
|
|
expect(getState("mcp-sid-ok")?.xml).toBe("<mxfile>user edit</mxfile>")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps a recovering tab's copy in history, never on the canvas", async () => {
|
|
|
|
|
setState("mcp-sid-recover", SAVED)
|
|
|
|
|
const { stateId, version } = await getJson("mcp-sid-recover")
|
|
|
|
|
const before = getHistory("mcp-sid-recover").length
|
|
|
|
|
const res = await postJson("/api/state", {
|
|
|
|
|
sessionId: "mcp-sid-recover",
|
|
|
|
|
xml: "<mxfile>what the tab showed</mxfile>",
|
|
|
|
|
baseVersion: version,
|
|
|
|
|
stateId,
|
|
|
|
|
source: "recover",
|
|
|
|
|
})
|
|
|
|
|
expect(res.status).toBe(409)
|
|
|
|
|
expect(JSON.parse(res.body).savedToHistory).toBe(true)
|
|
|
|
|
expect(getState("mcp-sid-recover")?.xml).toBe(SAVED)
|
|
|
|
|
expect(getHistory("mcp-sid-recover")).toHaveLength(before + 1)
|
|
|
|
|
expect(getHistory("mcp-sid-recover").at(-1)?.xml).toBe(
|
|
|
|
|
"<mxfile>what the tab showed</mxfile>",
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps the old rules for a tab from an older version", async () => {
|
|
|
|
|
// Its pushes have no stateId field
|
|
|
|
|
const version = setState("mcp-sid-legacy", "<mxfile>AI</mxfile>")
|
|
|
|
|
const res = await postJson("/api/state", {
|
|
|
|
|
sessionId: "mcp-sid-legacy",
|
|
|
|
|
xml: "<mxfile>edit</mxfile>",
|
|
|
|
|
baseVersion: version,
|
|
|
|
|
})
|
|
|
|
|
expect(res.status).toBe(200)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("preview page", () => {
|
|
|
|
|
it("shows the saved diagram of a session whose state expired", async () => {
|
|
|
|
|
const saved = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="kept" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
|
|
|
|
onSessionRecreate((id) => (id === "mcp-expired" ? saved : null))
|
|
|
|
|
try {
|
|
|
|
|
await request("/?mcp=mcp-expired")
|
|
|
|
|
expect(getState("mcp-expired")?.xml).toBe(saved)
|
|
|
|
|
await request("/?mcp=mcp-never-saved")
|
|
|
|
|
expect(getState("mcp-never-saved")?.xml).not.toContain("kept")
|
|
|
|
|
} finally {
|
|
|
|
|
onSessionRecreate(() => null)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("serves scripts that parse, with every placeholder filled", async () => {
|
|
|
|
|
const res = await request("/?mcp=mcp-test-script")
|
|
|
|
|
expect(res.body).not.toContain("{{")
|
|
|
|
|
// Both scripts share one global scope in the page
|
|
|
|
|
const scripts = [...res.body.matchAll(/<script>([\s\S]*?)<\/script>/g)]
|
|
|
|
|
.map((m) => m[1])
|
|
|
|
|
.join("\n")
|
|
|
|
|
expect(scripts).toContain('const sessionId = "mcp-test-script";')
|
2026-10-11 12:06:12 +09:00
|
|
|
expect(scripts).toContain(`let API_TOKEN = "${getApiToken()}";`)
|
2026-10-05 22:39:32 +09:00
|
|
|
expect(() => new Function(scripts)).not.toThrow()
|
|
|
|
|
})
|
2026-10-11 11:17:32 +09:00
|
|
|
|
|
|
|
|
it("embeds the bundled draw.io from its own origin, else the external one", async () => {
|
|
|
|
|
expect(isSameOriginDrawio()).toBe(true)
|
|
|
|
|
const res = await request("/?mcp=mcp-test-origin")
|
|
|
|
|
expect(res.body).toContain('src="/drawio/index.html?embed=1&')
|
|
|
|
|
// The page takes its own origin for draw.io's messages
|
|
|
|
|
expect(res.body).toContain(
|
|
|
|
|
'const DRAWIO_ORIGIN = "" || location.origin;',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
setDrawioDir(null)
|
|
|
|
|
try {
|
|
|
|
|
expect(isSameOriginDrawio()).toBe(false)
|
|
|
|
|
const external = await request("/?mcp=mcp-test-origin")
|
|
|
|
|
expect(external.body).toContain(
|
|
|
|
|
'src="https://embed.diagrams.net/?embed=1&',
|
|
|
|
|
)
|
|
|
|
|
expect(external.body).toContain(
|
|
|
|
|
'const DRAWIO_ORIGIN = "https://embed.diagrams.net" || location.origin;',
|
|
|
|
|
)
|
|
|
|
|
} finally {
|
|
|
|
|
setDrawioDir(drawioDir)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-11 12:06:12 +09:00
|
|
|
it("sends the security headers with the page, which is never cached", async () => {
|
2026-10-11 11:17:32 +09:00
|
|
|
const res = await request("/?mcp=mcp-test-headers")
|
|
|
|
|
expect(res.headers["content-security-policy"]).toBe(
|
|
|
|
|
"frame-ancestors 'self'",
|
|
|
|
|
)
|
|
|
|
|
expect(res.headers["x-content-type-options"]).toBe("nosniff")
|
2026-10-11 12:06:12 +09:00
|
|
|
// It holds the process's token
|
|
|
|
|
expect(res.headers["cache-control"]).toBe("no-store")
|
2026-10-11 11:17:32 +09:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("API token", () => {
|
|
|
|
|
it("refuses an API request without the token, or with another one", async () => {
|
|
|
|
|
setState("mcp-token", "<mxfile>kept</mxfile>")
|
|
|
|
|
for (const headers of [
|
|
|
|
|
{ "x-drawio-token": "" },
|
|
|
|
|
{ "x-drawio-token": "0123456789abcdef0123456789abcdef" },
|
|
|
|
|
]) {
|
|
|
|
|
const get = await request("/api/state?sessionId=mcp-token", {
|
|
|
|
|
headers,
|
|
|
|
|
})
|
|
|
|
|
expect(get.status).toBe(403)
|
|
|
|
|
const post = await postJson(
|
|
|
|
|
"/api/state",
|
|
|
|
|
{ sessionId: "mcp-token", xml: "<mxfile>replaced</mxfile>" },
|
|
|
|
|
headers,
|
|
|
|
|
)
|
|
|
|
|
expect(post.status).toBe(403)
|
|
|
|
|
const history = await request("/api/history?sessionId=mcp-token", {
|
|
|
|
|
headers,
|
|
|
|
|
})
|
|
|
|
|
expect(history.status).toBe(403)
|
|
|
|
|
}
|
|
|
|
|
expect(getState("mcp-token")?.xml).toBe("<mxfile>kept</mxfile>")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("is not needed for the page and the draw.io files", async () => {
|
|
|
|
|
const headers = { "x-drawio-token": "" }
|
|
|
|
|
expect((await request("/?mcp=mcp-token", { headers })).status).toBe(200)
|
|
|
|
|
expect((await request("/drawio/index.html", { headers })).status).toBe(
|
|
|
|
|
200,
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("bundled draw.io files", () => {
|
|
|
|
|
it("serves a file with its MIME type, cache and nosniff headers", async () => {
|
|
|
|
|
const res = await request("/drawio/js/app.min.js")
|
|
|
|
|
expect(res.status).toBe(200)
|
|
|
|
|
expect(res.body).toBe("// app")
|
|
|
|
|
expect(res.headers["content-type"]).toBe(
|
|
|
|
|
"text/javascript; charset=utf-8",
|
|
|
|
|
)
|
2026-10-11 12:06:12 +09:00
|
|
|
// Revalidated on every use: the file names stay the same across
|
|
|
|
|
// draw.io versions, so a package upgrade must reach the browser
|
|
|
|
|
expect(res.headers["cache-control"]).toBe("no-cache")
|
2026-10-11 12:42:54 +09:00
|
|
|
expect(res.headers.etag).toMatch(/^"v1-[0-9a-f]+-[0-9a-f]+"$/)
|
2026-10-11 11:17:32 +09:00
|
|
|
expect(res.headers["x-content-type-options"]).toBe("nosniff")
|
|
|
|
|
expect(res.headers["content-security-policy"]).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-11 12:42:54 +09:00
|
|
|
it("changes the ETag with the draw.io version, whatever the file dates say", async () => {
|
|
|
|
|
// An install that keeps the archive's dates gives the upgraded file
|
|
|
|
|
// the same date and, here, the same size as before
|
|
|
|
|
const file = join(drawioDir, "js/same-size.js")
|
|
|
|
|
const date = new Date("2020-01-01T00:00:00Z")
|
|
|
|
|
writeFileSync(file, "// v1")
|
|
|
|
|
utimesSync(file, date, date)
|
|
|
|
|
const first = await request("/drawio/js/same-size.js")
|
|
|
|
|
writeFileSync(file, "// v2")
|
|
|
|
|
utimesSync(file, date, date)
|
|
|
|
|
writeFileSync(join(drawioDir, ".version"), "v2\n")
|
|
|
|
|
setDrawioDir(drawioDir)
|
|
|
|
|
try {
|
|
|
|
|
const again = await request("/drawio/js/same-size.js", {
|
|
|
|
|
headers: { "if-none-match": first.headers.etag as string },
|
|
|
|
|
})
|
|
|
|
|
expect(again.status).toBe(200)
|
|
|
|
|
expect(again.body).toBe("// v2")
|
|
|
|
|
expect(again.headers.etag).not.toBe(first.headers.etag)
|
|
|
|
|
} finally {
|
|
|
|
|
writeFileSync(join(drawioDir, ".version"), "v1\n")
|
|
|
|
|
setDrawioDir(drawioDir)
|
|
|
|
|
rmSync(file, { force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-11 12:06:12 +09:00
|
|
|
it("answers 304 to a matching If-None-Match, and HEAD without a body", async () => {
|
|
|
|
|
const first = await request("/drawio/js/app.min.js")
|
|
|
|
|
const again = await request("/drawio/js/app.min.js", {
|
|
|
|
|
headers: { "if-none-match": first.headers.etag as string },
|
|
|
|
|
})
|
|
|
|
|
expect(again.status).toBe(304)
|
|
|
|
|
expect(again.body).toBe("")
|
|
|
|
|
expect(again.headers.etag).toBe(first.headers.etag)
|
|
|
|
|
const changed = await request("/drawio/js/app.min.js", {
|
|
|
|
|
headers: { "if-none-match": '"0-0"' },
|
|
|
|
|
})
|
|
|
|
|
expect(changed.status).toBe(200)
|
|
|
|
|
const head = await request("/drawio/js/app.min.js", {
|
|
|
|
|
method: "HEAD",
|
|
|
|
|
})
|
|
|
|
|
expect(head.status).toBe(200)
|
|
|
|
|
expect(head.body).toBe("")
|
|
|
|
|
expect(head.headers["content-length"]).toBe("6")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps running when a file cannot be read", async () => {
|
|
|
|
|
// Readable for stat, not for open (root reads anything: skipped)
|
|
|
|
|
if (process.getuid?.() === 0) return
|
|
|
|
|
const file = join(drawioDir, "js/locked.js")
|
|
|
|
|
writeFileSync(file, "// locked", { mode: 0o000 })
|
|
|
|
|
try {
|
|
|
|
|
// The connection is dropped (the headers may be out already)
|
|
|
|
|
const res = await request("/drawio/js/locked.js").catch(() => null)
|
|
|
|
|
expect(res?.body ?? "").toBe("")
|
|
|
|
|
} finally {
|
|
|
|
|
rmSync(file, { force: true })
|
|
|
|
|
}
|
|
|
|
|
const next = await request("/drawio/js/app.min.js")
|
|
|
|
|
expect(next.status).toBe(200)
|
|
|
|
|
expect(next.body).toBe("// app")
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-11 11:17:32 +09:00
|
|
|
it("frames the editor page only from this origin", async () => {
|
|
|
|
|
const res = await request("/drawio/index.html")
|
|
|
|
|
expect(res.status).toBe(200)
|
|
|
|
|
expect(res.headers["content-type"]).toBe("text/html; charset=utf-8")
|
|
|
|
|
expect(res.headers["content-security-policy"]).toBe(
|
|
|
|
|
"frame-ancestors 'self'",
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("never serves the war's server-side parts or files outside the copy", async () => {
|
|
|
|
|
for (const path of [
|
|
|
|
|
"/drawio/WEB-INF/web.xml",
|
|
|
|
|
"/drawio/web-inf/web.xml",
|
|
|
|
|
"/drawio/js/../WEB-INF/web.xml",
|
|
|
|
|
"/drawio/META-INF/MANIFEST.MF",
|
|
|
|
|
"/drawio/../package.json",
|
|
|
|
|
"/drawio/%2e%2e/package.json",
|
|
|
|
|
"/drawio/js/%2e%2e/%2e%2e/package.json",
|
|
|
|
|
"/drawio/js/..%5c..%5cpackage.json",
|
|
|
|
|
"/drawio/",
|
|
|
|
|
"/drawio/js",
|
|
|
|
|
"/drawio/missing.js",
|
|
|
|
|
]) {
|
|
|
|
|
const res = await request(path)
|
|
|
|
|
expect([403, 404], path).toContain(res.status)
|
|
|
|
|
expect(res.body).not.toContain("web-app")
|
|
|
|
|
expect(res.body).not.toContain('"name"')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("answers 404 when no copy is bundled", async () => {
|
|
|
|
|
setDrawioDir(null)
|
|
|
|
|
try {
|
|
|
|
|
expect((await request("/drawio/index.html")).status).toBe(404)
|
|
|
|
|
} finally {
|
|
|
|
|
setDrawioDir(drawioDir)
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-10-05 22:39:32 +09:00
|
|
|
})
|
|
|
|
|
|
2026-10-11 13:04:44 +09:00
|
|
|
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",
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-11 20:40:05 +09:00
|
|
|
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")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("turns dark mode on with the dark theme", () => {
|
|
|
|
|
// draw.io ignores ui=dark for dark mode once a dark parameter is sent
|
|
|
|
|
const params = drawioEmbedParams({ DRAWIO_UI: "dark" })
|
|
|
|
|
expect(params).toContain("dark=1")
|
|
|
|
|
expect(params).toContain("ui=dark")
|
|
|
|
|
// Unless the user asked for light mode
|
|
|
|
|
expect(drawioEmbedParams({ DRAWIO_UI: "dark", DRAWIO_DARK: "0" })).toBe(
|
|
|
|
|
"dark=0&ui=dark",
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("reads the theme in any case", () => {
|
|
|
|
|
expect(drawioEmbedParams({ DRAWIO_UI: "Dark" })).toBe("dark=1&ui=dark")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("ignores a theme or language draw.io does not know", () => {
|
|
|
|
|
expect(drawioEmbedParams({ DRAWIO_UI: "neon" })).toBe("dark=auto")
|
|
|
|
|
expect(drawioEmbedParams({ DRAWIO_LANG: "en;drop" })).toBe("dark=auto")
|
|
|
|
|
expect(drawioEmbedParams({ DRAWIO_LANG: "english" })).toBe("dark=auto")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("uses draw.io's name for Traditional Chinese", () => {
|
|
|
|
|
for (const value of ["zh-Hant", "zh-hant"]) {
|
|
|
|
|
expect(drawioEmbedParams({ DRAWIO_LANG: value })).toBe(
|
|
|
|
|
"dark=auto&lang=zh-tw",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
expect(drawioEmbedParams({ DRAWIO_LANG: "ja" })).toBe(
|
|
|
|
|
"dark=auto&lang=ja",
|
|
|
|
|
)
|
|
|
|
|
// and "zh" for Simplified Chinese
|
|
|
|
|
expect(drawioEmbedParams({ DRAWIO_LANG: "zh-Hans" })).toBe(
|
|
|
|
|
"dark=auto&lang=zh",
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("reads dark mode in any spelling", () => {
|
|
|
|
|
expect(drawioEmbedParams({ DRAWIO_DARK: "TRUE" })).toBe("dark=1")
|
|
|
|
|
expect(drawioEmbedParams({ DRAWIO_DARK: "light" })).toBe("dark=0")
|
|
|
|
|
expect(drawioEmbedParams({ DRAWIO_DARK: "maybe" })).toBe("dark=auto")
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-05 22:39:32 +09:00
|
|
|
describe("history restore", () => {
|
|
|
|
|
it("restores the entry the user picked after older entries drop", async () => {
|
|
|
|
|
const id = "mcp-history"
|
|
|
|
|
setState(id, "<mxfile/>")
|
|
|
|
|
for (let i = 0; i < 20; i++) addHistory(id, `<mxfile>${i}</mxfile>`)
|
|
|
|
|
|
|
|
|
|
const list = await request(`/api/history?sessionId=${id}`)
|
|
|
|
|
const picked = JSON.parse(list.body).entries[5]
|
|
|
|
|
|
|
|
|
|
// A new AI edit shifts the buffer before the user clicks Restore
|
|
|
|
|
addHistory(id, "<mxfile>new</mxfile>")
|
|
|
|
|
|
|
|
|
|
const res = await postJson("/api/restore", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
id: picked.id,
|
|
|
|
|
})
|
|
|
|
|
expect(res.status).toBe(200)
|
|
|
|
|
expect(getState(id)?.xml).toBe("<mxfile>5</mxfile>")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const page = (cellId: string) =>
|
|
|
|
|
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
|
|
|
|
|
2026-10-11 20:23:23 +09:00
|
|
|
it("refuses a restore picked from a state the server has since lost", async () => {
|
|
|
|
|
const id = "mcp-history-stale-state"
|
|
|
|
|
setState(id, page("now"))
|
|
|
|
|
addHistory(id, page("old"))
|
|
|
|
|
const [entry] = getHistory(id)
|
|
|
|
|
const stale = await postJson("/api/restore", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
id: entry.id,
|
|
|
|
|
stateId: "a-lost-state",
|
|
|
|
|
})
|
|
|
|
|
expect(stale.status).toBe(409)
|
|
|
|
|
// A list taken before the tab knew the state
|
|
|
|
|
const unknown = await postJson("/api/restore", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
id: entry.id,
|
|
|
|
|
stateId: null,
|
|
|
|
|
})
|
|
|
|
|
expect(unknown.status).toBe(409)
|
|
|
|
|
expect(getState(id)?.xml).toBe(page("now"))
|
|
|
|
|
const fresh = await postJson("/api/restore", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
id: entry.id,
|
|
|
|
|
stateId: getState(id)?.stateId,
|
|
|
|
|
})
|
|
|
|
|
expect(fresh.status).toBe(200)
|
|
|
|
|
expect(getState(id)?.xml).toBe(page("old"))
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-05 22:39:32 +09:00
|
|
|
// A thumbnail the tab took after loading the server write at `version`
|
|
|
|
|
const thumbnail = (id: string, svg: string, version: number) =>
|
|
|
|
|
postJson("/api/history-svg", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
svg,
|
|
|
|
|
stateId: getState(id)?.stateId,
|
|
|
|
|
version,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("gives a thumbnail only to the entry it shows", async () => {
|
|
|
|
|
const id = "mcp-history-thumb"
|
|
|
|
|
const version = setState(id, page("shown"))
|
|
|
|
|
// The last entry is another diagram (a tab's copy kept on recovery)
|
|
|
|
|
addHistory(id, page("other"))
|
|
|
|
|
await thumbnail(id, "SVG-OF-SHOWN", version)
|
|
|
|
|
expect(getHistory(id).at(-1)?.svg).toBe("")
|
|
|
|
|
addHistory(id, page("shown"))
|
|
|
|
|
await thumbnail(id, "SVG-OF-SHOWN", version)
|
|
|
|
|
expect(getHistory(id).at(-1)?.svg).toBe("SVG-OF-SHOWN")
|
|
|
|
|
expect(getState(id)?.svg).toBe("SVG-OF-SHOWN")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("drops a thumbnail that arrives after the next AI write", async () => {
|
|
|
|
|
const id = "mcp-history-thumb-late"
|
|
|
|
|
const first = setState(id, page("first"))
|
|
|
|
|
addHistory(id, page("first"))
|
|
|
|
|
setState(id, page("second"))
|
|
|
|
|
addHistory(id, page("second"))
|
|
|
|
|
await thumbnail(id, "SVG-OF-FIRST", first)
|
|
|
|
|
expect(getHistory(id).map((e) => e.svg)).toEqual(["", ""])
|
|
|
|
|
expect(getState(id)?.svg).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("drops a thumbnail of a state the server has since lost", async () => {
|
|
|
|
|
const id = "mcp-history-thumb-state"
|
|
|
|
|
const version = setState(id, page("shown"))
|
|
|
|
|
addHistory(id, page("shown"))
|
|
|
|
|
await postJson("/api/history-svg", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
svg: "SVG-OF-SHOWN",
|
|
|
|
|
stateId: "another-state",
|
|
|
|
|
version,
|
|
|
|
|
})
|
|
|
|
|
expect(getHistory(id).at(-1)?.svg).toBe("")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps a thumbnail in time after a sync reply", async () => {
|
|
|
|
|
const id = "mcp-history-thumb-sync"
|
|
|
|
|
const version = setState(id, page("ai"))
|
|
|
|
|
addHistory(id, page("ai"))
|
|
|
|
|
// draw.io's copy of the same diagram, sent back for a sync
|
|
|
|
|
const synced = page("ai").replace(
|
|
|
|
|
"<mxGraphModel>",
|
|
|
|
|
'<mxGraphModel dx="10">',
|
|
|
|
|
)
|
|
|
|
|
await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
xml: synced,
|
|
|
|
|
baseVersion: version,
|
|
|
|
|
source: "sync",
|
|
|
|
|
stateId: getState(id)?.stateId,
|
|
|
|
|
})
|
|
|
|
|
await thumbnail(id, "SVG-OF-AI", version)
|
|
|
|
|
expect(getHistory(id).at(-1)?.svg).toBe("SVG-OF-AI")
|
|
|
|
|
// The state's own image is of the synced XML only
|
|
|
|
|
expect(getState(id)?.svg).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps the image when a write repeats the same XML", async () => {
|
|
|
|
|
const id = "mcp-history-thumb-same"
|
|
|
|
|
const version = setState(id, page("same"))
|
|
|
|
|
await thumbnail(id, "SVG-OF-SAME", version)
|
|
|
|
|
setState(id, page("same"), undefined, true)
|
|
|
|
|
expect(getState(id)?.svg).toBe("SVG-OF-SAME")
|
|
|
|
|
setState(id, page("changed"), undefined, true)
|
|
|
|
|
expect(getState(id)?.svg).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("never pairs the image of an older diagram with a newer one", async () => {
|
|
|
|
|
const id = "mcp-history-stale-svg"
|
|
|
|
|
const version = setState(id, page("user"))
|
|
|
|
|
await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
xml: page("user2"),
|
|
|
|
|
svg: "SVG-OF-USER2",
|
|
|
|
|
baseVersion: version,
|
|
|
|
|
})
|
|
|
|
|
// An AI write without an image of its own
|
|
|
|
|
setState(id, page("ai"))
|
|
|
|
|
addHistory(id, page("older"))
|
|
|
|
|
const [entry] = getHistory(id)
|
|
|
|
|
await postJson("/api/restore", { sessionId: id, id: entry.id })
|
|
|
|
|
const kept = getHistory(id).find((e) => e.xml === page("ai"))
|
|
|
|
|
expect(kept?.svg).toBe("")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps a cleared document with renamed pages before restoring", async () => {
|
|
|
|
|
const id = "mcp-history-empty-pages"
|
|
|
|
|
const emptyPage = (name: string) =>
|
|
|
|
|
`<diagram id="${name}" name="${name}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram>`
|
|
|
|
|
const cleared = `<mxfile>${emptyPage("Planning")}${emptyPage("Notes")}</mxfile>`
|
|
|
|
|
addHistory(id, page("before"))
|
|
|
|
|
setState(id, cleared, undefined, true)
|
|
|
|
|
const [entry] = getHistory(id)
|
|
|
|
|
await postJson("/api/restore", { sessionId: id, id: entry.id })
|
|
|
|
|
expect(getHistory(id).map((e) => e.xml)).toContain(cleared)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("adds no entry for a copy of the last one", () => {
|
|
|
|
|
const id = "mcp-history-dedupe"
|
|
|
|
|
addHistory(id, page("same"))
|
|
|
|
|
addHistory(id, page("same"), "SVG")
|
|
|
|
|
expect(getHistory(id)).toHaveLength(1)
|
|
|
|
|
// The missing image is filled in
|
|
|
|
|
expect(getHistory(id)[0].svg).toBe("SVG")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// draw.io's own copy of a diagram, as a sync reply or an edit brings it
|
|
|
|
|
const drawioCopy = (xml: string, attrs = 'pageWidth="850"') =>
|
|
|
|
|
xml.replace(
|
|
|
|
|
"<mxGraphModel>",
|
|
|
|
|
`<mxGraphModel dx="1244" dy="534" grid="1" ${attrs}>`,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
it("keeps no copy of a server write that a sync brought back", async () => {
|
|
|
|
|
const id = "mcp-history-keep-sync"
|
|
|
|
|
const version = setState(id, page("ai"))
|
|
|
|
|
addHistory(id, page("ai"))
|
|
|
|
|
await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
xml: drawioCopy(page("ai")),
|
|
|
|
|
baseVersion: version,
|
|
|
|
|
source: "sync",
|
|
|
|
|
stateId: getState(id)?.stateId,
|
|
|
|
|
})
|
|
|
|
|
// The next AI write keeps the state it replaces
|
|
|
|
|
keepInHistory(id, getState(id)?.xml ?? "")
|
|
|
|
|
expect(getHistory(id)).toHaveLength(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps a change of page settings only before a write", async () => {
|
|
|
|
|
const id = "mcp-history-keep-settings"
|
|
|
|
|
const version = setState(id, page("ai"))
|
|
|
|
|
addHistory(id, page("ai"))
|
|
|
|
|
// The user turns the page to A3 in the preview
|
|
|
|
|
await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
xml: drawioCopy(page("ai"), 'pageWidth="1169" pageHeight="1654"'),
|
|
|
|
|
baseVersion: version,
|
|
|
|
|
stateId: getState(id)?.stateId,
|
|
|
|
|
})
|
|
|
|
|
keepInHistory(id, getState(id)?.xml ?? "")
|
|
|
|
|
expect(getHistory(id)).toHaveLength(2)
|
|
|
|
|
expect(getHistory(id).at(-1)?.xml).toContain('pageWidth="1169"')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps a diagram restored from its file before a write", () => {
|
|
|
|
|
const id = "mcp-history-keep-restored"
|
|
|
|
|
// As restoreSavedSession puts it back after a restart
|
|
|
|
|
setState(id, page("from file"), undefined, false, false)
|
|
|
|
|
keepInHistory(id, page("from file"))
|
|
|
|
|
expect(getHistory(id).map((e) => e.xml)).toEqual([page("from file")])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps a version that changed only the background", () => {
|
|
|
|
|
const id = "mcp-history-background"
|
|
|
|
|
addHistory(id, page("same"))
|
|
|
|
|
addHistory(
|
|
|
|
|
id,
|
|
|
|
|
page("same").replace(
|
|
|
|
|
"<mxGraphModel>",
|
|
|
|
|
'<mxGraphModel background="#FFE6CC">',
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
expect(getHistory(id)).toHaveLength(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps manual edits in history before restoring", async () => {
|
|
|
|
|
const id = "mcp-history-manual"
|
|
|
|
|
const doc = (cellId: string) =>
|
|
|
|
|
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
|
|
|
|
const version = setState(id, doc("ai"))
|
|
|
|
|
addHistory(id, doc("ai"))
|
|
|
|
|
// An edit in the browser is not a history entry by itself
|
|
|
|
|
const push = await postJson("/api/state", {
|
|
|
|
|
sessionId: id,
|
|
|
|
|
xml: doc("manual"),
|
|
|
|
|
baseVersion: version,
|
|
|
|
|
})
|
|
|
|
|
expect(push.status).toBe(200)
|
|
|
|
|
|
|
|
|
|
const [entry] = getHistory(id)
|
|
|
|
|
await postJson("/api/restore", { sessionId: id, id: entry.id })
|
|
|
|
|
expect(getState(id)?.xml).toBe(doc("ai"))
|
|
|
|
|
expect(getHistory(id).map((e) => e.xml)).toContain(doc("manual"))
|
|
|
|
|
})
|
2026-10-11 20:23:23 +09:00
|
|
|
|
|
|
|
|
it("restoreHistoryEntry leaves the canvas alone for an unknown entry", () => {
|
|
|
|
|
const id = "mcp-restore-unknown"
|
|
|
|
|
setState(id, page("x"))
|
|
|
|
|
addHistory(id, page("x"))
|
|
|
|
|
expect(restoreHistoryEntry(id, 999999)).toBeNull()
|
|
|
|
|
expect(getState(id)?.xml).toBe(page("x"))
|
|
|
|
|
expect(getHistory(id)).toHaveLength(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("restoreHistoryEntry restores an entry and keeps the canvas in History", () => {
|
|
|
|
|
const id = "mcp-restore-entry"
|
|
|
|
|
addHistory(id, page("old"))
|
|
|
|
|
const [entry] = getHistory(id)
|
|
|
|
|
// The canvas is a later state that is not in History yet
|
|
|
|
|
setState(id, page("ai"))
|
|
|
|
|
|
|
|
|
|
const newVersion = restoreHistoryEntry(id, entry.id)
|
|
|
|
|
expect(newVersion).toBe(getState(id)?.version)
|
|
|
|
|
expect(getState(id)?.xml).toBe(page("old"))
|
|
|
|
|
expect(getHistory(id).map((e) => e.xml)).toEqual([
|
|
|
|
|
page("old"),
|
|
|
|
|
page("ai"),
|
|
|
|
|
page("old"),
|
|
|
|
|
])
|
|
|
|
|
})
|
2026-10-05 22:39:32 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("bodies over the size limit", () => {
|
|
|
|
|
it("answers 413 after reading the whole body", async () => {
|
|
|
|
|
const mib = Buffer.alloc(1024 * 1024, "x")
|
|
|
|
|
// Still sending when the limit is passed, as a browser would be.
|
|
|
|
|
// A browser whose upload is cut off reports a network error.
|
|
|
|
|
const result = await new Promise<{ status?: number; sent: boolean }>(
|
|
|
|
|
(resolve) => {
|
|
|
|
|
let sent = false
|
|
|
|
|
const req = http.request(
|
|
|
|
|
{
|
|
|
|
|
host: "127.0.0.1",
|
|
|
|
|
port,
|
|
|
|
|
path: "/api/state",
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
host: `localhost:${port}`,
|
2026-10-11 11:17:32 +09:00
|
|
|
"x-drawio-token": getApiToken(),
|
2026-10-05 22:39:32 +09:00
|
|
|
"content-type": "application/json",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
(res) => {
|
|
|
|
|
res.resume()
|
|
|
|
|
res.on("end", () =>
|
|
|
|
|
resolve({ status: res.statusCode, sent }),
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
req.on("error", () => resolve({ sent }))
|
|
|
|
|
const writeNext = (i: number) => {
|
|
|
|
|
if (i === 15) {
|
|
|
|
|
req.end(() => {
|
|
|
|
|
sent = true
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
req.write(mib, () => setTimeout(() => writeNext(i + 1), 10))
|
|
|
|
|
}
|
|
|
|
|
writeNext(0)
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
expect(result).toEqual({ status: 413, sent: true })
|
|
|
|
|
})
|
|
|
|
|
})
|