mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-08 18:57:47 +08:00
fix: what the third round broke, and the first batch's review
MCP preview after the server lost a session (it expired, or the MCP process restarted): - Every server state has an id, made when the state is created. The tab notices a new id even when the version numbers happen to match, and every push names the state it was based on, so one based on a lost state is refused, also when it comes before the tab's first poll (the server recovers the saved file first). - The tab keeps the newest canvas XML, saved or not. When the server knows nothing (no file) or exactly what the tab last saved, the canvas wins and is saved, so edits made while the server was down are kept. Otherwise the server's diagram (an AI write the tab missed, a cleared document that was saved) is shown and the tab's copy goes to History. - Late answers to an old state's push or poll are dropped; a failed push says the server is unreachable; Download as .drawio saves the canvas. Settings and server: - Saved providers this version does not know stay in storage with their keys, and sending no longer trips over them. - The desktop "Ollama (Local)" preset with a key goes to local Ollama again; a server model's Ollama URL variable is read; the admin panel writes Ollama Cloud's URL for a key without one. - Provider error texts show again in the desktop app and for EdgeOne. - .env: a quoted value followed by a comment ending in a quote is read as dotenv reads it; unquoted values are unchanged. - Desktop app: the next launch opens the port where a chat was last saved; a launch elsewhere that saves nothing does not move it, and a page with no chats lets the next launch try the other port once. - The Test button no longer stays busy after another tab changed the key. - A completed append_diagram is no longer undone by an earlier failed edit's preview; a file read once in vain is saved again once it is read or gone. From the first batch's review: - The admin panel's Test of an entry without a URL now tests the server's <P>_BASE_URL, where chat sends the entry's key; chat is unchanged (the first fix rerouted working setups). - The model list ends downloads that are too large, accepts answers without a body, and keeps the "redirects are not allowed" explanation. - A test covers the preview's History rendering.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
* Serves draw.io embed with state sync and history UI
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { readFileSync } from "node:fs"
|
||||
import http from "node:http"
|
||||
import { dirname, join } from "node:path"
|
||||
@@ -105,11 +106,22 @@ function ensureSessionStateInitialized(sessionId: string): void {
|
||||
// A blank diagram keeps the draw.io spinner (spin=1) from waiting
|
||||
// forever when no load(xml) is ever sent
|
||||
setState(sessionId, BLANK_MXFILE, undefined, false, false)
|
||||
// Nothing is known about this session: a tab that still shows it keeps
|
||||
// its diagram
|
||||
const state = stateStore.get(sessionId)
|
||||
if (state) state.blank = true
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
xml: string
|
||||
version: number
|
||||
// Made when the state is created (first use, or again after it expired
|
||||
// or the MCP process restarted) and kept by every write. A tab tells by
|
||||
// it that the server lost what it knew, and every push names the state
|
||||
// it was based on, so one based on a lost state is refused.
|
||||
stateId: string
|
||||
// Created blank because nothing was saved; cleared by the first write
|
||||
blank?: boolean
|
||||
// Version of the last write the browser did not make itself (AI edit,
|
||||
// restore). A browser push based on an older version is rejected.
|
||||
serverVersion?: number
|
||||
@@ -178,6 +190,7 @@ export function setState(
|
||||
stateStore.set(sessionId, {
|
||||
xml,
|
||||
version: newVersion,
|
||||
stateId: existing?.stateId ?? randomUUID(),
|
||||
serverVersion: fromBrowser ? existing?.serverVersion : newVersion,
|
||||
lastUpdated: new Date(),
|
||||
lastPolled: existing?.lastPolled,
|
||||
@@ -444,6 +457,8 @@ function handleStateApi(
|
||||
JSON.stringify({
|
||||
xml: state?.xml || null,
|
||||
version: state?.version || 0,
|
||||
stateId: state?.stateId ?? null,
|
||||
blank: !!state?.blank,
|
||||
syncRequested: !!state?.syncRequested,
|
||||
exportFormat: state?.exportFormat || null,
|
||||
exportXml: state?.exportXml || null,
|
||||
@@ -486,12 +501,58 @@ function handleStateApi(
|
||||
return
|
||||
}
|
||||
|
||||
// A push can come before the tab's first poll after a
|
||||
// restart: recover the saved file first, so it is compared
|
||||
// with that and never overwrites it unseen
|
||||
ensureSessionStateInitialized(sessionId)
|
||||
const current = stateStore.get(sessionId)
|
||||
|
||||
// A tab of this version names the state its push is based
|
||||
// on. Another state (the server lost the one it knew, or
|
||||
// the tab has not polled yet): refused, and the tab's next
|
||||
// poll decides whose diagram wins.
|
||||
if (current && "stateId" in data) {
|
||||
if (data.stateId !== current.stateId) {
|
||||
res.writeHead(409, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "Session was recreated",
|
||||
stateChanged: true,
|
||||
version: current.version,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
// What a recovering tab showed: kept in history only
|
||||
if (data.source === "recover") {
|
||||
const saved =
|
||||
typeof data.xml === "string" &&
|
||||
!!data.xml &&
|
||||
data.xml !== current.xml
|
||||
if (saved) {
|
||||
addHistory(sessionId, data.xml, data.svg || "")
|
||||
}
|
||||
res.writeHead(409, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "Diagram changed on the server",
|
||||
version: current.version,
|
||||
savedToHistory: saved,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// The browser edited a version older than the latest AI write
|
||||
// (it has not loaded that write yet). Keep the AI write; the
|
||||
// browser loads it on its next poll. A sync reply is also
|
||||
// stale after a newer write of the browser's own (a user
|
||||
// edit saved while the export ran).
|
||||
const current = stateStore.get(sessionId)
|
||||
if (
|
||||
typeof data.baseVersion === "number" &&
|
||||
(data.baseVersion < (current?.serverVersion ?? 0) ||
|
||||
|
||||
@@ -56,15 +56,22 @@ export class Autosaver {
|
||||
}
|
||||
|
||||
// Saved files that could not be read back: never written over, since
|
||||
// the session then shows something else than what they hold
|
||||
// the session then shows something else than what they hold. Cleared
|
||||
// once the file is read, or is gone.
|
||||
private unreadable = new Set<string>()
|
||||
|
||||
/** The session's saved diagram, or null. */
|
||||
load(sessionId: string): string | null {
|
||||
const path = this.pathFor(sessionId)
|
||||
if (!path || !existsSync(path)) return null
|
||||
if (!path) return null
|
||||
if (!existsSync(path)) {
|
||||
this.unreadable.delete(path)
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return readFileSync(path, "utf-8")
|
||||
const xml = readFileSync(path, "utf-8")
|
||||
this.unreadable.delete(path)
|
||||
return xml
|
||||
} catch (error) {
|
||||
log.warn(`Could not read the saved diagram ${path}: ${error}`)
|
||||
this.unreadable.add(path)
|
||||
@@ -93,7 +100,13 @@ export class Autosaver {
|
||||
const entry = this.pending.get(sessionId)
|
||||
this.pending.delete(sessionId)
|
||||
const path = this.pathFor(sessionId)
|
||||
if (!entry || !this.dir || !path || this.unreadable.has(path)) return
|
||||
if (!entry || !this.dir || !path) return
|
||||
if (this.unreadable.has(path)) {
|
||||
log.warn(
|
||||
`Not saving ${path}: it could not be read, so it may hold work this session does not show`,
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const isNew = !existsSync(path)
|
||||
// A blank page the browser shows before any drawing: nothing to keep
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
const iframe = document.getElementById('drawio');
|
||||
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||
// The server state this tab is in step with (see stateId in http-server.ts);
|
||||
// null until the first poll
|
||||
let stateId = null;
|
||||
// The newest diagram on the canvas, saved to the server or not: lastXml is
|
||||
// the last one the server has
|
||||
let latestXml = null;
|
||||
let pushFailing = false; // the last push could not reach the server
|
||||
let pollSeq = 0, lastHandledPoll = 0; // polls overlap; older answers are dropped
|
||||
let pendingSvgExport = null;
|
||||
let pendingSvgBase = 0; // version the pending autosave was based on
|
||||
let pendingSvgStateId = null; // and the state it belonged to
|
||||
let pendingAiSvg = false;
|
||||
let pendingMcpExport = null; // 'png', 'svg' or 'xmlsvg' when MCP requested export
|
||||
let mcpExportSeq = 0; // number of the latest MCP export
|
||||
@@ -17,19 +26,24 @@ window.addEventListener('message', (e) => {
|
||||
if (msg.event === 'init') {
|
||||
isReady = true;
|
||||
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
||||
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml) {
|
||||
// Ignore autosave while a single-page projection is on screen
|
||||
// for a page-targeted export — otherwise we'd push the
|
||||
// transient projection back as the canonical session state.
|
||||
if (projectionExportActive) return;
|
||||
// Also an edit undone back to what the server has
|
||||
latestXml = msg.xml;
|
||||
if (msg.xml === lastXml) return;
|
||||
// Request SVG export, then push state with SVG. Remember the
|
||||
// version this edit is based on, so the server can reject it
|
||||
// if the AI wrote a newer version that is not loaded yet.
|
||||
// version and state this edit is based on, so the server can
|
||||
// reject it if the AI wrote a newer version that is not loaded
|
||||
// yet, or if it lost that state.
|
||||
pendingSvgExport = msg.xml;
|
||||
pendingSvgBase = currentVersion;
|
||||
pendingSvgStateId = stateId;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||
// Fallback if export doesn't respond
|
||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase); pendingSvgExport = null; } }, 2000);
|
||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase, 'edit', pendingSvgStateId); pendingSvgExport = null; } }, 2000);
|
||||
} else if (msg.event === 'export' && msg.format === 'xml') {
|
||||
// Sync export requested by the server (get_diagram).
|
||||
// draw.io returns the XML in msg.xml, with no msg.data. A late
|
||||
@@ -39,7 +53,7 @@ window.addEventListener('message', (e) => {
|
||||
// Push with the version the export was taken at: a
|
||||
// newer AI write may have loaded meanwhile, and this
|
||||
// older XML must not overwrite it.
|
||||
pushState(msg.xml, '', pendingSyncBase, 'sync');
|
||||
pushState(msg.xml, '', pendingSyncBase, 'sync', pendingSyncStateId);
|
||||
}
|
||||
} else if (msg.event === 'export' && msg.data) {
|
||||
// Handle MCP server export request (png/svg). fireExport tags
|
||||
@@ -98,7 +112,7 @@ window.addEventListener('message', (e) => {
|
||||
if (pendingSvgExport) {
|
||||
const xml = pendingSvgExport;
|
||||
pendingSvgExport = null;
|
||||
pushState(xml, svg, pendingSvgBase);
|
||||
pushState(xml, svg, pendingSvgBase, 'edit', pendingSvgStateId);
|
||||
} else if (pendingAiSvg) {
|
||||
pendingAiSvg = false;
|
||||
fetch('/api/history-svg', {
|
||||
@@ -114,6 +128,7 @@ window.addEventListener('message', (e) => {
|
||||
function loadDiagram(xml, capturePreview = false) {
|
||||
if (!isReady) { pendingXml = xml; return; }
|
||||
lastXml = xml;
|
||||
latestXml = xml;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
|
||||
if (capturePreview) {
|
||||
setTimeout(() => {
|
||||
@@ -144,24 +159,27 @@ function showNotice(text) {
|
||||
noticeTimer = setTimeout(() => el.classList.remove('open'), 8000);
|
||||
}
|
||||
|
||||
// Same rule as hasCells in pages.ts: a cell besides the root cells, or a
|
||||
// compressed page
|
||||
function hasCells(xml) {
|
||||
return /<(mxCell\b[^>]*\bid\s*=\s*["'](?![01]["'])|UserObject\b|object\b)|<diagram\b[^>]*>\s*[^\s<]/.test(xml || '');
|
||||
}
|
||||
|
||||
// source is 'sync' for replies to a server sync request, 'recover' for the
|
||||
// tab's copy after the server recovered the session, else 'edit'
|
||||
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit') {
|
||||
// tab's copy after the server recovered the session, else 'edit'. sid is the
|
||||
// server state the push is based on.
|
||||
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit', sid = stateId) {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const r = await fetch('/api/state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source })
|
||||
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source, stateId: sid })
|
||||
});
|
||||
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
|
||||
// 409: the AI wrote a newer version; load it now
|
||||
pushFailing = false;
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
// An answer about a state this tab has left since
|
||||
if (sid !== stateId) return;
|
||||
currentVersion = d.version;
|
||||
lastXml = xml;
|
||||
}
|
||||
// 409: the AI wrote a newer version, or the server lost the state
|
||||
// this push was based on; the next poll sorts it out
|
||||
else if (r.status === 409) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (d.savedToHistory) {
|
||||
@@ -171,33 +189,63 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
|
||||
}
|
||||
poll();
|
||||
}
|
||||
} catch (e) { console.error('Push failed:', e); }
|
||||
} catch (e) {
|
||||
console.error('Push failed:', e);
|
||||
if (!pushFailing) {
|
||||
pushFailing = true;
|
||||
showNotice("Can't reach the MCP server. Your changes are only in this tab for now; use Download to keep a copy.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The server made a new state for this session: it expired, or the MCP
|
||||
// process restarted. Decide whose diagram wins.
|
||||
function recoverState(s) {
|
||||
stateId = s.stateId;
|
||||
// The old state's pending work is gone with it
|
||||
const projectionShown = projectionExportActive;
|
||||
projectionExportActive = false;
|
||||
forceReload = false;
|
||||
pendingMcpExport = null;
|
||||
pendingSyncExport = false;
|
||||
const mine = latestXml;
|
||||
currentVersion = s.version;
|
||||
if (s.blank || s.xml === lastXml) {
|
||||
// The server knows nothing, or exactly what this tab last saved:
|
||||
// the canvas can only be newer, so it wins (edits made while the
|
||||
// server was down are saved now)
|
||||
if (projectionShown && mine) {
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: mine, autosave: 1 }), '*');
|
||||
}
|
||||
if (mine && mine !== s.xml) pushState(mine, '', s.version);
|
||||
} else {
|
||||
// The server has a diagram this tab never showed (an AI write it
|
||||
// missed, a saved file): show that, and keep this tab's copy in
|
||||
// History unless it is the same
|
||||
loadDiagram(s.xml, true);
|
||||
if (mine && mine !== s.xml) pushState(mine, '', s.version, 'recover');
|
||||
}
|
||||
}
|
||||
|
||||
let pendingSyncExport = false;
|
||||
let pendingSyncBase = 0; // version the pending sync export was taken at
|
||||
let pendingSyncStateId = null; // and the state it belonged to
|
||||
let syncExportSeq = 0; // number of the latest sync export
|
||||
|
||||
async function poll() {
|
||||
if (!sessionId) return;
|
||||
const knownVersion = currentVersion;
|
||||
const seq = ++pollSeq;
|
||||
try {
|
||||
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
||||
if (!r.ok) return;
|
||||
const s = await r.json();
|
||||
// The server lost this session (it expired, or the MCP process
|
||||
// restarted) and rebuilt it. Blank: push back what the browser
|
||||
// shows. From the auto-save file, which can hold an AI write this
|
||||
// tab never loaded: show that, and keep this tab's copy in History
|
||||
// (a push based on version 0 is refused and saved there).
|
||||
if (s.version < knownVersion && lastXml) {
|
||||
if (!hasCells(s.xml)) {
|
||||
pushState(lastXml);
|
||||
} else {
|
||||
currentVersion = 0;
|
||||
if (s.xml !== lastXml) pushState(lastXml, '', 0, 'recover');
|
||||
}
|
||||
}
|
||||
// An older answer than one already handled (the interval, the 409
|
||||
// handler and the projection restore each poll): it could name a
|
||||
// state that is gone
|
||||
if (seq < lastHandledPoll) return;
|
||||
lastHandledPoll = seq;
|
||||
if (stateId === null) stateId = s.stateId;
|
||||
else if (s.stateId && s.stateId !== stateId) recoverState(s);
|
||||
// Load new diagram from server (before export, so we export latest).
|
||||
// While a page-targeted projection is on screen, only the restore
|
||||
// (forceReload) replaces it, so a new version doesn't fight the
|
||||
@@ -217,6 +265,7 @@ async function poll() {
|
||||
if (s.syncRequested && !pendingSyncExport && isReady && !projectionExportActive) {
|
||||
pendingSyncExport = true;
|
||||
pendingSyncBase = currentVersion;
|
||||
pendingSyncStateId = stateId;
|
||||
// draw.io echoes the request in msg.message, so the reply can
|
||||
// be matched to this request
|
||||
const seq = ++syncExportSeq;
|
||||
@@ -324,7 +373,8 @@ saveConfirmBtn.onclick = () => {
|
||||
// so no wrapper injection is needed. The legacy fallback below
|
||||
// remains only for documents that somehow slipped past
|
||||
// normalisation (e.g. an older session loaded from external state).
|
||||
let xmlData = lastXml || '';
|
||||
// The canvas as it is, also edits not saved to the server yet
|
||||
let xmlData = latestXml || lastXml || '';
|
||||
if (xmlData && !xmlData.includes('<mxfile')) {
|
||||
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
||||
}
|
||||
|
||||
@@ -337,6 +337,87 @@ describe("export requests", () => {
|
||||
})
|
||||
})
|
||||
|
||||
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>`
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
@@ -102,6 +103,33 @@ describe("Autosaver", () => {
|
||||
expect(readFileSync(path, "utf-8")).toBe(DIAGRAM)
|
||||
})
|
||||
|
||||
it("saves again once the file was read, or is gone", () => {
|
||||
const saver = new Autosaver(tempDir(), 10)
|
||||
saver.schedule("mcp-fixed", DIAGRAM)
|
||||
saver.flush()
|
||||
const path = saver.pathFor("mcp-fixed") as string
|
||||
chmodSync(path, 0o000)
|
||||
expect(saver.load("mcp-fixed")).toBeNull()
|
||||
// Permissions fixed; the session is recreated and reads the file
|
||||
chmodSync(path, 0o644)
|
||||
expect(saver.load("mcp-fixed")).toBe(DIAGRAM)
|
||||
const edited = DIAGRAM.replace('id="a"', 'id="b"')
|
||||
saver.schedule("mcp-fixed", edited)
|
||||
saver.flush()
|
||||
expect(readFileSync(path, "utf-8")).toBe(edited)
|
||||
|
||||
// A file that could not be read and was then deleted protects
|
||||
// nothing any more
|
||||
chmodSync(path, 0o000)
|
||||
expect(saver.load("mcp-fixed")).toBeNull()
|
||||
chmodSync(path, 0o644)
|
||||
rmSync(path)
|
||||
expect(saver.load("mcp-fixed")).toBeNull()
|
||||
saver.schedule("mcp-fixed", DIAGRAM)
|
||||
saver.flush()
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user