mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 12:09:53 +08:00
fix(mcp-server): recover sessions in one place, and more fixes from the third review
- A session whose state expired was recovered from its auto-save file only for the preview page; the tools built on their older copy and then overwrote the file. They now recover it first (restoreSavedSession). - A preview tab that missed the last AI write pushed its older diagram over the recovered one after a restart. It now shows the recovered diagram and keeps its own copy in History. - An empty record of what the model has seen (after load_diagram or a page tool on unseen changes) no longer lets one page of a multi-page document count for all, and get_diagram counts a page only once found. - History: a thumbnail goes only to the entry it shows, the cached image never belongs to an older diagram, a re-serialized copy adds no entry, and a cleared document with its own pages is kept before a restore. - The root cell id check reads attributes one by one: rack-id="1" or id text inside a label no longer counts. - A compressed page counts as having cells; a saved file that could not be read is never written over.
This commit is contained in:
@@ -156,9 +156,15 @@ describe("markPageSeen", () => {
|
||||
expect(checkEditGate(marked, live).ok).toBe(false)
|
||||
})
|
||||
|
||||
it("counts everything as seen when the model saw nothing before", () => {
|
||||
// It has no old copy of the other pages to edit from
|
||||
it("does not count other pages when the record of what was seen is empty", () => {
|
||||
// Empty also after load_diagram or a page tool on unseen changes,
|
||||
// when the model may still remember an older copy of the pages
|
||||
const live = doc("a1", "b1")
|
||||
expect(markPageSeen("", live, { page_id: "A" })).toBe("")
|
||||
})
|
||||
|
||||
it("counts a one-page document as seen from its only page", () => {
|
||||
const live = `<mxfile>${page("A", "a1")}</mxfile>`
|
||||
expect(markPageSeen("", live, { page_id: "A" })).toBe(live)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import http from "node:http"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { installDomPolyfill } from "../src/dom.ts"
|
||||
import { addHistory, getHistory } from "../src/history.ts"
|
||||
import {
|
||||
getState,
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
let port = 0
|
||||
|
||||
beforeAll(async () => {
|
||||
// XML parsing, as the server installs it at startup
|
||||
installDomPolyfill()
|
||||
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
|
||||
})
|
||||
|
||||
@@ -350,6 +353,67 @@ describe("history restore", () => {
|
||||
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>`
|
||||
|
||||
it("gives a thumbnail only to the entry it shows", async () => {
|
||||
const id = "mcp-history-thumb"
|
||||
setState(id, page("shown"))
|
||||
// The last entry is another diagram (a tab's copy kept on recovery)
|
||||
addHistory(id, page("other"))
|
||||
await postJson("/api/history-svg", {
|
||||
sessionId: id,
|
||||
svg: "SVG-OF-SHOWN",
|
||||
})
|
||||
expect(getHistory(id).at(-1)?.svg).toBe("")
|
||||
addHistory(id, page("shown"))
|
||||
await postJson("/api/history-svg", {
|
||||
sessionId: id,
|
||||
svg: "SVG-OF-SHOWN",
|
||||
})
|
||||
expect(getHistory(id).at(-1)?.svg).toBe("SVG-OF-SHOWN")
|
||||
})
|
||||
|
||||
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 re-serialized copy of the last one", () => {
|
||||
const id = "mcp-history-dedupe"
|
||||
addHistory(id, page("same"))
|
||||
addHistory(
|
||||
id,
|
||||
page("same").replace("<mxGraphModel>", '<mxGraphModel dx="10">'),
|
||||
)
|
||||
expect(getHistory(id)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("keeps manual edits in history before restoring", async () => {
|
||||
const id = "mcp-history-manual"
|
||||
const doc = (cellId: string) =>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
@@ -84,6 +85,23 @@ describe("Autosaver", () => {
|
||||
expect(new Autosaver(null).load("mcp-back")).toBeNull()
|
||||
})
|
||||
|
||||
it("never replaces a saved file it could not read", () => {
|
||||
const saver = new Autosaver(tempDir(), 10)
|
||||
saver.schedule("mcp-locked", DIAGRAM)
|
||||
saver.flush()
|
||||
const path = saver.pathFor("mcp-locked") as string
|
||||
chmodSync(path, 0o000)
|
||||
try {
|
||||
expect(saver.load("mcp-locked")).toBeNull()
|
||||
// The blank page shown instead must not overwrite the diagram
|
||||
saver.schedule("mcp-locked", BLANK)
|
||||
saver.flush()
|
||||
} finally {
|
||||
chmodSync(path, 0o644)
|
||||
}
|
||||
expect(readFileSync(path, "utf-8")).toBe(DIAGRAM)
|
||||
})
|
||||
|
||||
it("does nothing when saving is off", () => {
|
||||
const saver = new Autosaver(null)
|
||||
expect(saver.pathFor("mcp-x")).toBeNull()
|
||||
|
||||
@@ -85,6 +85,15 @@ describe("hasCells", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("counts a compressed page as having cells", () => {
|
||||
// draw.io's compressed format: the page's model is text
|
||||
expect(
|
||||
hasCells(
|
||||
`<mxfile><diagram id="p" name="P">dZHBDoIwDIafhjtsGPWM6MkTB8/LVmBxrGQMQZ/eLRuIUS/bv/VfmybF</diagram></mxfile>`,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("counts cells with spaces around the =", () => {
|
||||
expect(hasCells(`<mxCell id = "a" vertex="1" parent="1"/>`)).toBe(true)
|
||||
expect(
|
||||
@@ -106,6 +115,27 @@ describe("reservedIdError", () => {
|
||||
`<UserObject id="1" label="Docs"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>`,
|
||||
),
|
||||
).toMatch(/"0" and "1"/)
|
||||
// Other attributes ending in id, and id text inside a label
|
||||
expect(
|
||||
reservedIdError(
|
||||
`<UserObject label="Server" rack-id="1" id="7"><mxCell vertex="1" parent="1"/></UserObject>`,
|
||||
),
|
||||
).toBeNull()
|
||||
expect(
|
||||
reservedIdError(
|
||||
`<UserObject id="u" label="Example: id='1'"><mxCell vertex="1" parent="1"/></UserObject>`,
|
||||
),
|
||||
).toBeNull()
|
||||
expect(
|
||||
reservedIdError(
|
||||
`<mxCell id="5" data-id="0" vertex="1" parent="1"/>`,
|
||||
),
|
||||
).toBeNull()
|
||||
expect(
|
||||
reservedIdError(
|
||||
`<mxCell id="5" value="id="1" vertex="1"" vertex="1" parent="1"/>`,
|
||||
),
|
||||
).toBeNull()
|
||||
// A whole model has its own root cells
|
||||
expect(
|
||||
reservedIdError(
|
||||
|
||||
Reference in New Issue
Block a user