/** * Tests for XML syntax checking, autoFixXml and the XML serializer. * * linkedom (the DOM used in Node) parses leniently and never reports syntax * errors, so validation relies on the strict check in dom.ts. autoFixXml * runs on the whole document whenever any check fails, so its steps must * leave valid parts of the document untouched. */ import { beforeAll, describe, expect, it } from "vitest" import { installDomPolyfill } from "../src/dom.ts" import { getXmlSyntaxError } from "../src/xml-syntax.ts" beforeAll(() => { installDomPolyfill() }) import { addPageToDoc, parseMxfile, serializeMxfile } from "../src/pages.ts" import { validateAndFixXml } from "../src/xml-validation.ts" /** Bare model with the root cells plus the given cells. */ const model = (cells: string) => `${cells}` // A bare & makes the first validation fail, which triggers autoFixXml on // the whole document. const BROKEN_CELL = `` describe("getXmlSyntaxError", () => { it("rejects an attribute prefix that was never declared", () => { // The browser's DOMParser, and so draw.io, rejects it too expect(getXmlSyntaxError(``)).toMatch( /prefix/, ) expect( getXmlSyntaxError( ``, ), ).toBeNull() }) it("accepts well-formed XML", () => { expect(getXmlSyntaxError(model(""))).toBeNull() }) it.each([ ["duplicate attribute", ``], ["unquoted attribute", ``], ["missing space between attributes", ``], ["bare ampersand", ``], ["unclosed tag", ``], ["plain text", `hello`], ])("reports %s", (_name, xml) => { expect(getXmlSyntaxError(xml)).toMatch(/^\d+:\d+: /) }) }) describe("validateAndFixXml", () => { it("rejects a duplicate style attribute", () => { const r = validateAndFixXml( model( ``, ), ) expect(r.valid).toBe(false) expect(r.error).toContain("duplicate attribute: style") }) it("rejects an unquoted attribute value", () => { const r = validateAndFixXml( model(``), ) expect(r.valid).toBe(false) }) it("keeps style values intact while fixing another cell", () => { const cells = `` const r = validateAndFixXml(model(cells + BROKEN_CELL)) expect(r.valid).toBe(true) expect(r.fixed).toContain('style="shape=cylinder3;whiteSpace=wrap;"') expect(r.fixed).toContain('style="edgeStyle=orthogonalEdgeStyle;"') expect(r.fixed).toContain('value="R&D"') }) it("keeps " inside rich-text labels", () => { const rich = `` const r = validateAndFixXml(model(rich + BROKEN_CELL)) expect(r.valid).toBe(true) expect(r.fixed).toContain( 'value="<font style="color: red;">Hi</font>"', ) expect(getXmlSyntaxError(r.fixed ?? "")).toBeNull() }) it("keeps UserObject and object wrappers", () => { const wrapped = `` const r = validateAndFixXml(model(wrapped + BROKEN_CELL)) expect(r.valid).toBe(true) expect(r.fixed).toContain(' { const xml = [ "", "", '', '', BROKEN_CELL, '', "", "", ].join("\n") const r = validateAndFixXml(xml) expect(r.valid).toBe(true) expect(r.fixes).toEqual(["Escaped unescaped & characters"]) }) it("leaves cells split over two lines alone while fixing another cell", () => { const xml = [ "", '', '', ' ', '', ' ', BROKEN_CELL, "", ].join("\n") const r = validateAndFixXml(xml) expect(r.valid).toBe(true) expect(r.fixes).toEqual(["Escaped unescaped & characters"]) }) it("renames duplicate short ids without touching the attribute name", () => { const r = validateAndFixXml( model( ``, ), ) expect(r.valid).toBe(true) expect(r.fixed).toContain('id="d_dup1"') expect(r.fixed).toContain('id="i_dup1"') }) it("adds a missing space between attributes", () => { const r = validateAndFixXml( model( ``, ), ) expect(r.valid).toBe(true) expect(r.fixed).toContain(' { const r = validateAndFixXml( model( ``, ), ) expect(r.valid).toBe(true) expect(r.fixed).toContain('value="Hello"') }) }) describe("XML serializer and strict parsing in page helpers", () => { it("keeps line breaks and tabs in attribute values", () => { const xml = `` const out = serializeMxfile(parseMxfile(xml) as Document) expect(out).toContain('value="Multi-Head Attention x"') expect(out).not.toMatch(/value="[^"]*\n/) }) it("escapes special characters in attributes and text", () => { const xml = `a < b` const out = serializeMxfile(parseMxfile(xml) as Document) expect(out).toBe(xml) }) it("parseMxfile returns null for malformed XML", () => { expect( parseMxfile( ``, ), ).toBeNull() }) it("addPageToDoc rejects malformed page XML", () => { const doc = parseMxfile( `${model("")}`, ) as Document expect(() => addPageToDoc(doc, { xml: model(``), }), ).toThrow() }) }) describe("autoFixXml keeps valid tags", () => { it("removes a stray without touching ", () => { const r = validateAndFixXml( model(``), ) expect(r.valid).toBe(true) expect(r.fixed).toContain("") expect(r.fixed).toContain("") expect(r.fixed).not.toContain("") expect(r.fixed).toContain('id="2"') }) it("removes a stray without touching waypoints", () => { const edge = `` const r = validateAndFixXml(model(`${edge}`)) expect(r.valid).toBe(true) expect(r.fixed).toContain('') expect(r.fixed).toContain('') expect(r.fixed).not.toContain("") }) it("fixes a lowercase instead of deleting every cell", () => { const r = validateAndFixXml( model( `${BROKEN_CELL}`, ), ) expect(r.valid).toBe(true) expect(r.fixed).toContain('') expect(r.fixed).toContain('value="R&D"') }) it("keeps label text when removing a foreign tag", () => { const cell = `` const r = validateAndFixXml(model(`${cell}`)) expect(r.valid).toBe(true) expect(r.fixed).toContain('value="<b>Bold</b>"') expect(r.fixed).not.toContain("") }) }) describe("validateAndFixXml strict checks", () => { it("fixes the case of an unknown element name", () => { const r = validateAndFixXml( model(``), ) expect(r.valid).toBe(true) expect(r.fixes).toContain("Fixed tag case of ") expect(r.fixed).toContain(' { const cell = `` const r = validateAndFixXml(model(cell)) expect(r.valid).toBe(true) expect(r.fixed).not.toContain('') expect(r.fixed).toContain('as="sourcePoint"') expect(r.fixed).toContain('') }) it("finds an orphan mxPoint after an empty ", () => { const edge = (id: string, points: string) => `${points}` const r = validateAndFixXml( model( edge("e1", ``) + `` + edge( "e2", ``, ), ), ) expect(r.valid).toBe(true) expect(r.fixed).not.toContain('') expect(r.fixed).toContain('') }) }) describe("text between tags", () => { // draw.io reads any text inside a page as compressed data, so the whole // page fails to open with an atob error it("turns a literal \\n between tags into a line break", () => { const cell = `\\n \\n` const r = validateAndFixXml(cell) expect(r.valid).toBe(true) expect(r.fixed).toBe( `\n \n`, ) }) it("rejects other text between tags", () => { const r = validateAndFixXml( model( `Reset password`, ), ) expect(r.valid).toBe(false) expect(r.error).toMatch(/Reset password/) }) it("accepts a compressed page", () => { expect( validateAndFixXml( `dZHBDoIwDIafhjtsGPWM6MkTB8/LVmBxrGQMQZ/eLRuIUS/bv/VfmybF`, ).valid, ).toBe(true) }) }) describe("text directly under a page", () => { it("fixes a literal \\n before the model of a page", () => { const r = validateAndFixXml( `\\n${model(``)}`, ) expect(r.valid).toBe(true) expect(r.fixed).not.toContain("\\n") }) it("rejects CDATA text before the model of a page", () => { const r = validateAndFixXml( `${model(``)}`, ) expect(r.valid).toBe(false) expect(r.error).toMatch(/not-base64/) }) }) describe("attributes inside quoted values", () => { const labelled = `` it("are not duplicates of the real ones", () => { const r = validateAndFixXml(model(labelled)) expect(r.valid).toBe(true) expect(r.fixed ?? model(labelled)).toContain(labelled) }) it("are kept when a real duplicate is removed", () => { // The bare & makes the repair run on the whole document const cell = `` const r = validateAndFixXml(model(cell + BROKEN_CELL)) expect(r.valid).toBe(true) expect(r.fixed).toContain( ``, ) }) it("leave two cells with an unbalanced quote their ids and parents", () => { const broken = `` const r = validateAndFixXml(model(broken)) expect(r.fixed).toContain(`id="5" value="B" vertex="1" parent="1"`) expect(r.fixed).toMatch(/id="4"[^>]*vertex="1" parent="1"/) }) })