mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29:51 +08:00
Chats: - New Chat right after an answer saves that chat once. Saves run one at a time and read the chat on screen when their turn comes; a save scheduled for a chat that is no longer on screen is dropped. A chat whose id was still on its way to the URL no longer comes back after New Chat (the next answer went into it). - Crossing the 768 px breakpoint keeps the chat panel: a streaming answer, unsaved messages and attachments stay. The panel gets the sizes of each side, and a panel collapsed on desktop opens on mobile. - The chat's export waits for its own reply: an edit's history export still on its way no longer answers it with the older diagram, and two file saves at once no longer swap results. - A second edit in one answer is previewed on the first edit's result. - Stop also ends a running screenshot check; a chat that cannot be saved (storage full) can be left with "Continue without saving". - Small diagrams with shapes count as diagrams; the tool card no longer crashes on malformed operations. Quota and providers: - Requests that reach the server's own endpoints count toward the quota: EdgeOne (always its own endpoint now), a private base URL whatever key header is sent, keyless Ollama without a URL. With the quota on, a redirect is followed only to a public address. The output cap applies to these requests too. - Stop records the tokens of the steps that finished; the screenshot check counts its tokens without counting a request. - EdgeOne configured only by AI_PROVIDER works, also in the admin Test, which forwards the access code. Azure set up only in the admin panel works in chat. The Test sends a Bedrock session token. - The admin panel's Test of an entry without a URL uses the server's URL as the server does (no private address check for it); the admin panel no longer writes an Ollama URL. MCP server: - Write tools and start_session run one at a time, so two at once never drop each other's change; a cancelled call waiting its turn is skipped. get_diagram and export_diagram keep the session they started with. - Export to .drawio first gets the user's latest edits from the browser. - History thumbnails: one that arrives after the next AI write is dropped; a sync reply keeps the image; a version that changed only page settings is its own entry. - A diagram over the 10 MB limit is saved without its image, or the user is told to download it (the server now answers 413 instead of cutting the connection). - Labels holding text like id='1' or parent='1' are no longer read as attributes (a layer or a parent was deleted). A broken bare <mxGraphModel> file is refused. - After a sync reply the tab no longer sends its autosave copy again. Desktop and files: - A newer switch of the same preset is not rolled back by an older one that failed. .env values with escaped quotes are read whole. - MCP saved files: a file that could not be read stays protected while a folder without permission hides it, and is saved again once deleted. - The desktop app reports "no chats" only when the count was read and no model settings are stored.
127 lines
4.8 KiB
TypeScript
127 lines
4.8 KiB
TypeScript
/**
|
|
* Unit tests for load_diagram's file parsing (load-diagram.ts).
|
|
*
|
|
* A .drawio file stores each page's <mxGraphModel> either as plain XML or
|
|
* as draw.io's compressed default (encodeURIComponent → raw deflate →
|
|
* base64 text content). The loader must produce the canonical session
|
|
* shape: an <mxfile> whose every page is plain XML.
|
|
*/
|
|
|
|
import { deflateRawSync } from "node:zlib"
|
|
import { DOMParser } from "linkedom"
|
|
import { beforeAll, describe, expect, it } from "vitest"
|
|
import { installDomPolyfill } from "../src/dom.ts"
|
|
|
|
// Install the DOM polyfills exactly as index.ts does at runtime.
|
|
beforeAll(() => {
|
|
installDomPolyfill()
|
|
})
|
|
|
|
import {
|
|
decompressPageContent,
|
|
parseDrawioFileContent,
|
|
} from "../src/load-diagram.ts"
|
|
|
|
const MODEL_XML = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
|
|
|
|
/** Compress a page body exactly the way draw.io does when saving. */
|
|
function drawioCompress(xml: string): string {
|
|
return deflateRawSync(
|
|
Buffer.from(encodeURIComponent(xml), "utf-8"),
|
|
).toString("base64")
|
|
}
|
|
|
|
const PLAIN_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1">${MODEL_XML}</diagram></mxfile>`
|
|
const COMPRESSED_MXFILE = `<mxfile host="app.diagrams.net" compressed="true"><diagram id="p1" name="Page-1">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
|
|
|
|
describe("decompressPageContent", () => {
|
|
it("round-trips draw.io's compressed format", () => {
|
|
expect(decompressPageContent(drawioCompress(MODEL_XML))).toBe(MODEL_XML)
|
|
})
|
|
|
|
it("handles non-URI-encoded legacy payloads", () => {
|
|
const legacy = deflateRawSync(Buffer.from(MODEL_XML, "utf-8")).toString(
|
|
"base64",
|
|
)
|
|
expect(decompressPageContent(legacy)).toBe(MODEL_XML)
|
|
})
|
|
|
|
it("returns null for garbage", () => {
|
|
expect(decompressPageContent("not base64 deflate")).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe("parseDrawioFileContent", () => {
|
|
it("passes a plain-XML mxfile through unchanged", () => {
|
|
const r = parseDrawioFileContent(PLAIN_MXFILE)
|
|
expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE })
|
|
})
|
|
|
|
it("wraps a bare mxGraphModel into a one-page mxfile", () => {
|
|
const r = parseDrawioFileContent(MODEL_XML)
|
|
expect(r.ok).toBe(true)
|
|
if (r.ok) {
|
|
expect(r.xml).toContain("<mxfile")
|
|
expect(r.xml).toContain('value="Hello"')
|
|
}
|
|
})
|
|
|
|
it("rejects a bare mxGraphModel that is not closed", () => {
|
|
const r = parseDrawioFileContent(
|
|
MODEL_XML.replace("</root></mxGraphModel>", ""),
|
|
)
|
|
expect(r.ok).toBe(false)
|
|
})
|
|
|
|
it("decompresses a compressed mxfile into plain XML pages", () => {
|
|
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
|
|
expect(r.ok).toBe(true)
|
|
if (r.ok) {
|
|
expect(r.xml).toContain("<mxGraphModel")
|
|
expect(r.xml).toContain('value="Hello"')
|
|
// The compressed blob must be gone.
|
|
expect(r.xml).not.toContain(drawioCompress(MODEL_XML))
|
|
}
|
|
})
|
|
|
|
it("decompresses only the compressed pages of a mixed file", () => {
|
|
const mixed = `<mxfile><diagram id="a" name="Plain">${MODEL_XML}</diagram><diagram id="b" name="Squeezed">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
|
|
const r = parseDrawioFileContent(mixed)
|
|
expect(r.ok).toBe(true)
|
|
if (r.ok) {
|
|
const doc = new DOMParser().parseFromString(r.xml, "text/xml")
|
|
const diagrams = Array.from(
|
|
doc.querySelectorAll("diagram"),
|
|
) as Element[]
|
|
expect(diagrams).toHaveLength(2)
|
|
for (const d of diagrams) {
|
|
expect(d.querySelector("mxGraphModel")).not.toBeNull()
|
|
}
|
|
}
|
|
})
|
|
|
|
it("keeps empty pages as-is", () => {
|
|
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
|
|
const r = parseDrawioFileContent(withEmpty)
|
|
expect(r).toEqual({ ok: true, xml: withEmpty })
|
|
})
|
|
|
|
it("rejects empty files", () => {
|
|
const r = parseDrawioFileContent(" ")
|
|
expect(r.ok).toBe(false)
|
|
})
|
|
|
|
it("rejects non-drawio content", () => {
|
|
const r = parseDrawioFileContent("<svg><rect/></svg>")
|
|
expect(r.ok).toBe(false)
|
|
if (!r.ok) expect(r.error).toContain("Not a draw.io file")
|
|
})
|
|
|
|
it("rejects a page whose content is neither XML nor compressed", () => {
|
|
const bad = `<mxfile><diagram id="a" name="Broken">!!! not a diagram !!!</diagram></mxfile>`
|
|
const r = parseDrawioFileContent(bad)
|
|
expect(r.ok).toBe(false)
|
|
if (!r.ok) expect(r.error).toContain('"Broken"')
|
|
})
|
|
})
|