import { describe, expect, it } from "vitest"; import { ToolcraftArtifactExportError } from "./export-error"; import { createToolcraftSvgDocument, createToolcraftSvgProductContainer, serializeToolcraftSvgDocument, } from "./svg-document"; import { TOOLCRAFT_SVG_NAMESPACE } from "./svg-policy"; const frame = Object.freeze({ height: 80, width: 120, x: -10, y: 5 }); function createSvgElement(name: string): Readonly<{ container: SVGGElement; element: Element; }> { const { container } = createToolcraftSvgProductContainer(); const element = container.ownerDocument.createElementNS( TOOLCRAFT_SVG_NAMESPACE, name, ); container.append(element); return { container, element }; } describe("Toolcraft SVG document", () => { it("serializes valued attributes as strict standalone XML", () => { const { document } = createToolcraftSvgDocument(frame); const { container } = createToolcraftSvgProductContainer(); const rect = container.ownerDocument.createElementNS( TOOLCRAFT_SVG_NAMESPACE, "rect", ); rect.setAttribute("data-column-track", "true"); rect.setAttribute("height", "40"); rect.setAttribute("width", "20"); container.append(rect); const artifact = serializeToolcraftSvgDocument({ backgroundColor: "#123456", container, document, frame, }); const parsed = new DOMParser().parseFromString( artifact.source, "image/svg+xml", ); expect(artifact.source).toContain('data-column-track="true"'); expect(parsed.querySelector("parsererror")).toBeNull(); expect(parsed.documentElement.namespaceURI).toBe(TOOLCRAFT_SVG_NAMESPACE); expect(parsed.documentElement.getAttribute("viewBox")).toBe("-10 5 120 80"); expect( parsed.documentElement.children[0]?.getAttribute( "data-toolcraft-background", ), ).toBe("true"); expect( parsed.documentElement.children[1]?.getAttribute( "data-toolcraft-product-scene", ), ).toBe("true"); expect(artifact.vectorElementCount).toBe(1); }); it.each([ "animate", "discard", "foreignObject", "image", "script", "style", ])("rejects forbidden <%s> content", (elementName) => { const { container } = createSvgElement(elementName); expect(() => serializeToolcraftSvgDocument({ backgroundColor: null, container, document: createToolcraftSvgDocument(frame).document, frame, }), ).toThrow(ToolcraftArtifactExportError); }); it.each([ ["onclick", "alert(1)", "event attribute"], ["href", "https://example.com/shape.svg#icon", "external href"], ["fill", "url(https://example.com/fill.svg#paint)", "external url()"], ])("rejects unsafe %s attributes", (name, value, message) => { const { container } = createToolcraftSvgProductContainer(); const use = container.ownerDocument.createElementNS( TOOLCRAFT_SVG_NAMESPACE, "use", ); use.setAttribute(name, value); container.append(use); expect(() => serializeToolcraftSvgDocument({ backgroundColor: null, container, document: createToolcraftSvgDocument(frame).document, frame, }), ).toThrow(message); }); it("rejects documents without a visible product vector", () => { const { container } = createToolcraftSvgProductContainer(); const defs = container.ownerDocument.createElementNS( TOOLCRAFT_SVG_NAMESPACE, "defs", ); const path = container.ownerDocument.createElementNS( TOOLCRAFT_SVG_NAMESPACE, "path", ); defs.append(path); container.append(defs); expect(() => serializeToolcraftSvgDocument({ backgroundColor: null, container, document: createToolcraftSvgDocument(frame).document, frame, }), ).toThrow("visible vector primitive"); }); it("rejects product attempts to mutate runtime-owned markers", () => { const { container, element } = createSvgElement("rect"); element.setAttribute("data-toolcraft-background", "true"); expect(() => serializeToolcraftSvgDocument({ backgroundColor: null, container, document: createToolcraftSvgDocument(frame).document, frame, }), ).toThrow("runtime-owned marker attributes"); element.removeAttribute("data-toolcraft-background"); container.removeAttribute("data-toolcraft-product-scene"); expect(() => serializeToolcraftSvgDocument({ backgroundColor: null, container, document: createToolcraftSvgDocument(frame).document, frame, }), ).toThrow("runtime-owned SVG group"); }); it("requires unique ids and resolved local references", () => { const { container } = createToolcraftSvgProductContainer(); const defs = container.ownerDocument.createElementNS( TOOLCRAFT_SVG_NAMESPACE, "defs", ); const shape = container.ownerDocument.createElementNS( TOOLCRAFT_SVG_NAMESPACE, "path", ); shape.setAttribute("id", "shape"); defs.append(shape); const use = container.ownerDocument.createElementNS( TOOLCRAFT_SVG_NAMESPACE, "use", ); use.setAttribute("href", "#missing"); container.append(defs, use); expect(() => serializeToolcraftSvgDocument({ backgroundColor: null, container, document: createToolcraftSvgDocument(frame).document, frame, }), ).toThrow("#missing has no matching element"); use.setAttribute("href", "#shape"); const duplicate = container.ownerDocument.createElementNS( TOOLCRAFT_SVG_NAMESPACE, "rect", ); duplicate.setAttribute("id", "shape"); container.append(duplicate); expect(() => serializeToolcraftSvgDocument({ backgroundColor: null, container, document: createToolcraftSvgDocument(frame).document, frame, }), ).toThrow("non-empty and unique"); }); it("rejects non-content XML nodes", () => { const { container } = createSvgElement("rect"); container.append( container.ownerDocument.createProcessingInstruction( "xml-stylesheet", 'href="https://example.com/styles.css"', ), ); expect(() => serializeToolcraftSvgDocument({ backgroundColor: null, container, document: createToolcraftSvgDocument(frame).document, frame, }), ).toThrow("only elements and text nodes"); }); });