mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
## Problem
Two audits of the repository (a workflow of finder, dedup and per-file verifier agents, run on 2026-10-11 against 1f4141e and again against 07f3e3c after a first round of refactoring) found dead code and features that were more machinery than the app needs: an Electron configuration-preset system with its own settings window, three copies of the about page, a hand-written `.env` parser, an "extended" system prompt nobody selected, two data migrations for formats from 2025-12 and 2026-01, a server-side example cache, type-to-confirm provider deletion, 81 unused dictionary keys, wrapper components around single call sites, and a long tail of unused exports, props, options and tests.
## Change
Every item below was either approved one by one or covered by "fix all of the others".
- **Electron:** the configuration-preset system is gone (settings window, preload, IPC, menu entry, dev watcher); `config-manager.ts` keeps only `userLocale`; `.env` files are parsed with `dotenv`. Entitlements file and a duplicate CI workflow removed; `test.yml` gets a type check and concurrency.
- **Server:** no extended prompt, no example cache, no `ENABLE_HISTORY_XML_REPLACE`, no message metadata or `hasConfig`; the connection test is one shared helper; EdgeOne loses its mock branch and model aliases; `DEBUG_LLM_PAYLOAD` logs message structure instead of payloads; Langfuse scoring helper; `apiKeyEnv` arrays kept and documented in `env.example` and the provider docs.
- **Pages:** one about page per language from a single component (old `/cn` and `/ja` URLs redirect with 308); layout uses `hasLocale`; the GitHub icon is a local component instead of `react-icons`; Cloudflare deploys without an R2 cache by default.
- **Chat and canvas:** the legacy `diagramHistory` conversion, the localStorage chat migration and the templates storage are gone; `chat-engine` reads the diagram through the context's refs; the diagram context has one tagged export path (`requestTaggedExport` / `fetchDiagramXml`); the screenshot check uses fetch + JSON and `generateText` with `Output.object`; `load-diagram` accepts `<svg:svg>` and strips the XML prolog.
- **Settings UI:** `model-selector` inlines its Dialog, Command and Tooltip; `ai-elements/model-selector`, `button-with-tooltip`, `error-toast` removed; no type-to-confirm, no footer, no `bedrockApiKey`; `use-model-config` loses the old-format migration; one reasoning component; the shimmer is CSS (no `motion`, no `@radix-ui/react-use-controllable-state`); 81 dictionary keys removed in all four languages.
- **MCP server:** `registerTool` / `ok` / `fail` replace per-tool try/catch; `syncFromBrowser`, `commitDocument`, `describePages`; the hand-written XML syntax check, unused result fields, `pickPageSelector`, `expandStyles` and the `host` attribute handling are gone; tests get a shared `setup.ts` and drive the server through the SDK client.
- **Prompt:** the web app's style rules come from the MCP drawing guide (`styleRules`), byte-identical to before.
- **Tests:** shared e2e helpers (`openApp`, `sse`, `toolCallEvents`, `chunkedReplies`); placeholder specs dropped; `settings-review-fixes` split by topic; unit tests use `vi.stubEnv`.
- **Dependencies removed:** `@xmldom/xmldom`, `remark-gfm`, `@anthropic-ai/tokenizer`, `@testing-library/user-event`, `concurrently`, `wait-on`, `eslint`, `eslint-config-next`, `@vitest/coverage-v8`, `shx`, `motion`, `@radix-ui/react-use-controllable-state`, `react-icons`.
Source lines: 74,983 before, 67,418 after (excluding lockfiles).
The last commit merges #984 (the canvas shell). Where the shell, the classic page or their tests depend on a wire format or signature this branch had changed, main's version wins (export fields of the poll response, `addHistory` signature); where main only extended code this branch restructured, main's change is carried into the new structure (`start_session`, `get_selection`, `prepareNewDiagram`, the diagram context's `requestExport`).
## Testing
- `tsc --noEmit` for the web app, `packages/mcp-server`, its shell and `electron`: clean
- `biome ci`: 0 errors (37 pre-existing warnings)
- Root `vitest run`: 719 passed; `packages/mcp-server` `vitest run`: 372 passed
- `next build`: clean
- Playwright, whole web suite on the dev server (4 workers): 280 tests, 279 passed; `language.spec.ts` "a stale offer to switch without saving does nothing once a message is being sent" failed under parallel load and passed alone, as it does on main
200 lines
7.3 KiB
TypeScript
200 lines
7.3 KiB
TypeScript
/**
|
|
* Tests for the diagram history: the versions restore_version can go to.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest"
|
|
import {
|
|
addHistory,
|
|
clearHistory,
|
|
currentEntryId,
|
|
firstCopyIds,
|
|
getHistory,
|
|
isInHistory,
|
|
otherVersions,
|
|
} from "../src/history.ts"
|
|
|
|
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>`
|
|
|
|
const A = page("a")
|
|
const B = page("b")
|
|
const C = page("c")
|
|
|
|
// draw.io's copy of B: other attribute order, whitespace and viewport
|
|
// attributes, same diagram
|
|
const B_COPY = `<mxfile host="app.diagrams.net">
|
|
<diagram name="P" id="p">
|
|
<mxGraphModel dx="100" dy="200" pageWidth="850">
|
|
<root>
|
|
<mxCell id="0" />
|
|
<mxCell id="1" parent="0" />
|
|
<mxCell id="b" parent="1" vertex="1" />
|
|
</root>
|
|
</mxGraphModel>
|
|
</diagram>
|
|
</mxfile>`
|
|
|
|
const fill = (id: string, versions: string[]) => {
|
|
clearHistory(id)
|
|
for (const xml of versions) addHistory(id, xml)
|
|
}
|
|
|
|
describe("otherVersions", () => {
|
|
it("lists the versions before the canvas, newest first", () => {
|
|
const id = "history-abc"
|
|
fill(id, [A, B, C])
|
|
expect(otherVersions(id, C).map((e) => e.xml)).toEqual([B, A])
|
|
})
|
|
|
|
it("lists each version once after an undo, the left one first", () => {
|
|
const id = "history-abcb"
|
|
fill(id, [A, B, C, B])
|
|
expect(otherVersions(id, B).map((e) => e.xml)).toEqual([C, A])
|
|
})
|
|
|
|
it("lists every version when the canvas is in none of them", () => {
|
|
const id = "history-manual"
|
|
fill(id, [A, B, C])
|
|
expect(otherVersions(id, page("manual")).map((e) => e.xml)).toEqual([
|
|
C,
|
|
B,
|
|
A,
|
|
])
|
|
})
|
|
|
|
it("counts a re-serialised copy as the same version", () => {
|
|
const id = "history-copy"
|
|
fill(id, [A, B, B_COPY])
|
|
expect(otherVersions(id, B).map((e) => e.xml)).toEqual([A])
|
|
expect(otherVersions(id, B_COPY).map((e) => e.xml)).toEqual([A])
|
|
})
|
|
|
|
it("keeps the newest copy of a repeated version", () => {
|
|
const id = "history-newest"
|
|
fill(id, [A, B, A])
|
|
const [newestA] = otherVersions(id, B)
|
|
expect(newestA.id).toBe(getHistory(id)[2].id)
|
|
expect(otherVersions(id, B)).toHaveLength(1)
|
|
})
|
|
|
|
it("matches a bare model from the browser with its named page", () => {
|
|
const id = "history-bare"
|
|
const named = (xml: string) => xml.replace('name="P"', 'name="Arch"')
|
|
fill(id, [named(A), named(B)])
|
|
// The browser's sync sent page B without its <mxfile> wrapper
|
|
const bareB = B.replace(/^.*?(<mxGraphModel>)/, "$1").replace(
|
|
"</diagram></mxfile>",
|
|
"",
|
|
)
|
|
expect(otherVersions(id, bareB).map((e) => e.xml)).toEqual([named(A)])
|
|
expect(isInHistory(id, bareB)).toBe(true)
|
|
expect(isInHistory(id, C)).toBe(false)
|
|
})
|
|
|
|
it("still counts a rename when an unrelated bare model is in History", () => {
|
|
const id = "history-mixed"
|
|
const bareX = page("x")
|
|
.replace(/^.*?(<mxGraphModel>)/, "$1")
|
|
.replace("</diagram></mxfile>", "")
|
|
const arch = B.replace('name="P"', 'name="Arch"')
|
|
const overview = B.replace('name="P"', 'name="Overview"')
|
|
fill(id, [bareX, arch])
|
|
expect(otherVersions(id, overview).map((e) => e.xml)).toEqual([
|
|
arch,
|
|
bareX,
|
|
])
|
|
expect(isInHistory(id, overview)).toBe(false)
|
|
})
|
|
|
|
it("returns nothing for an empty history", () => {
|
|
const id = "history-empty"
|
|
clearHistory(id)
|
|
expect(otherVersions(id, A)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe("entries", () => {
|
|
it("record who made them", () => {
|
|
const id = "history-fields"
|
|
clearHistory(id)
|
|
addHistory(id, A)
|
|
addHistory(id, B, "<svg/>", "user")
|
|
addHistory(id, C, "", "restore")
|
|
const [first, second, third] = getHistory(id)
|
|
expect(first.source).toBeUndefined()
|
|
expect(second.source).toBe("user")
|
|
expect(second.svg).toBe("<svg/>")
|
|
expect(third.source).toBe("restore")
|
|
})
|
|
})
|
|
|
|
// The shell's version cards: which entry the canvas shows, and which
|
|
// entries are copies of an earlier one (a restore adds one)
|
|
describe("currentEntryId and firstCopyIds", () => {
|
|
it("name the newest entry with the canvas's content, by the rule of otherVersions", () => {
|
|
const id = "history-current"
|
|
fill(id, [A, B, C, A])
|
|
const [a, b, c, aCopy] = getHistory(id)
|
|
expect(currentEntryId(id, A)).toBe(aCopy.id)
|
|
expect(currentEntryId(id, B_COPY)).toBe(b.id)
|
|
expect(currentEntryId(id, page("manual"))).toBeNull()
|
|
// A restore's copy belongs with its first entry; the others are
|
|
// their own
|
|
expect([...firstCopyIds(id).entries()]).toEqual([
|
|
[a.id, a.id],
|
|
[b.id, b.id],
|
|
[c.id, c.id],
|
|
[aCopy.id, a.id],
|
|
])
|
|
})
|
|
|
|
it("group a bare model with the first named page it matches, never a later one", () => {
|
|
// A bare model (the browser's sync can send one) has no page name:
|
|
// it is the same as "Arch" and as "Overview", which differ from each
|
|
// other. Each entry names a first copy with its own content, so a
|
|
// card never shows one document and restores another
|
|
const id = "history-bare-groups"
|
|
const named = (name: string) => A.replace('name="P"', `name="${name}"`)
|
|
const bare = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel>`
|
|
fill(id, [named("Arch"), bare, named("Overview")])
|
|
const [arch, model, overview] = getHistory(id)
|
|
const firsts = firstCopyIds(id)
|
|
expect([...firsts.entries()]).toEqual([
|
|
[arch.id, arch.id],
|
|
[model.id, arch.id],
|
|
[overview.id, overview.id],
|
|
])
|
|
// The entry named as first has the content of every entry in its
|
|
// group (otherVersions of that content never lists it)
|
|
for (const entry of getHistory(id)) {
|
|
expect(otherVersions(id, entry.xml).map((e) => e.id)).not.toContain(
|
|
firsts.get(entry.id),
|
|
)
|
|
}
|
|
})
|
|
|
|
it("agree with otherVersions on a page setting that only the canvas shows", () => {
|
|
// The same cells with another background: one version to the MCP
|
|
// core (the model's edits stay valid when the user changes a page
|
|
// setting), so the canvas still shows that entry and restore_version
|
|
// offers no other copy of it
|
|
const id = "history-background"
|
|
const withBackground = A.replace(
|
|
"<mxGraphModel>",
|
|
'<mxGraphModel background="#ffcc00" shadow="1">',
|
|
)
|
|
fill(id, [A, B])
|
|
const [a] = getHistory(id)
|
|
expect(currentEntryId(id, withBackground)).toBe(a.id)
|
|
expect(otherVersions(id, withBackground).map((e) => e.xml)).toEqual([B])
|
|
expect(isInHistory(id, withBackground)).toBe(true)
|
|
})
|
|
|
|
it("return nothing for an empty history", () => {
|
|
const id = "history-current-empty"
|
|
clearHistory(id)
|
|
expect(currentEntryId(id, A)).toBeNull()
|
|
expect(firstCopyIds(id).size).toBe(0)
|
|
})
|
|
})
|