pike/glyph Public Public: anyone can read this; nobody can change it.
forked from apps/glyph
workbenchthis machine
Files 5c2d82ded0cf18c81c70d9f4a3f1de0b1688b125
| 1 | /** |
| 2 | * The wall every SVG passes, the reply read back, and the palette swap. |
| 3 | * The wall is the one place a model's output meets the page, so it is |
| 4 | * tested by what gets through: nothing that runs, loads or links. |
| 5 | */ |
| 6 | import { expect, test } from "bun:test"; |
| 7 | import { claudeEnv, drawArgs, resultText } from "./claude.ts"; |
| 8 | import { claudeDrawer } from "./draw.ts"; |
| 9 | import { ask, GLOW, INK, inPalette, nameOf, PALETTES, PAPER, parseReply, sanitize } from "./glyph.ts"; |
| 10 | |
| 11 | const ok = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>Hex Chip</title><path d="M12 2 L52 2" stroke="${INK}" stroke-width="4" stroke-linecap="round"/><circle cx="32" cy="32" r="6" fill="${GLOW}"/></svg>`; |
| 12 | |
| 13 | test("a clean SVG comes through as it was", () => { |
| 14 | expect(sanitize(ok)).toBe(ok); |
| 15 | expect(nameOf(ok)).toBe("Hex Chip"); |
| 16 | }); |
| 17 | |
| 18 | test("nothing that runs, loads or links gets through", () => { |
| 19 | const evil = `<svg viewBox="0 0 64 64" onload="alert(1)" style="x"> |
| 20 | <script>alert(1)</script> |
| 21 | <foreignObject><div onclick="x">hi</div></foreignObject> |
| 22 | <image href="https://evil.example/x.png"/> |
| 23 | <a href="javascript:alert(1)"><circle cx="1" cy="1" r="1"/></a> |
| 24 | <use href="https://evil.example/s.svg#x"/> |
| 25 | <style>@import url(https://evil.example)</style> |
| 26 | <path d="M0 0L9 9" fill="url(https://evil.example/#g)" onmouseover="x" stroke="${INK}" transform="translate(2 2)"/> |
| 27 | <rect x="1" y="1" width="5" height="5" transform="expression(alert(1))"/> |
| 28 | <!-- <script>alert(2)</script> --> |
| 29 | <![CDATA[ <script>alert(3)</script> ]]> |
| 30 | </svg>`; |
| 31 | const clean = sanitize(evil)!; |
| 32 | expect(clean).toBeDefined(); |
| 33 | for (const bad of ["script", "onload", "onclick", "onmouseover", "foreignObject", "image", "href", "javascript", "style", "url(", "evil", "expression", "<a", "<use", "alert"]) |
| 34 | expect([bad, clean.includes(bad)]).toEqual([bad, false]); |
| 35 | // What was allowed inside survives: the path, its colour and its transform; the link's child went with it. |
| 36 | expect(clean).toContain(`<path d="M0 0L9 9" stroke="${INK}" transform="translate(2 2)"/>`); |
| 37 | expect(clean).toContain(`<rect x="1" y="1" width="5" height="5"/>`); |
| 38 | expect(clean).not.toContain("<circle"); |
| 39 | }); |
| 40 | |
| 41 | test("a value is a value: no colour functions, only a transform's own and a local url", () => { |
| 42 | const svg = sanitize(`<svg viewBox="0 0 8 8"><circle r="1" fill="rgb(1,2,3)" stroke="hsl(1,2%,3%)" transform="rotate(45 4 4)"/></svg>`)!; |
| 43 | expect(svg).toContain(`<circle r="1" transform="rotate(45 4 4)"/>`); |
| 44 | // A url points inside the SVG, from a paint or a clip, and nowhere else. |
| 45 | const refs = sanitize(`<svg viewBox="0 0 8 8"><circle r="1" fill="url(#a1)" stroke="url(data:x)" clip-path="url(#c)" opacity="url(#a1)"/><rect width="1" height="1" fill="url( #a1)" stroke="url(#a1) url(#b)"/></svg>`)!; |
| 46 | expect(refs).toContain(`<circle r="1" fill="url(#a1)" clip-path="url(#c)"/>`); |
| 47 | expect(refs).toContain(`<rect width="1" height="1"/>`); |
| 48 | expect(sanitize(`<svg viewBox="0 0 8 8"><circle r="1" id="1x" fill="url(#1x)"/><circle r="1" id="ok-1"/></svg>`)).toContain(`<circle r="1"/><circle r="1" id="ok-1"/>`); |
| 49 | }); |
| 50 | |
| 51 | test("depth gets through: gradients, stops and clips, in the placeholder colours", () => { |
| 52 | const deep = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><title>Lit Orb</title><defs><radialGradient id="a1" cx="0.35" cy="0.3" r="0.8" fx="0.3" fy="0.25" gradientTransform="rotate(10 0.5 0.5)"><stop offset="0" stop-color="${GLOW}"/><stop offset="1" stop-color="${INK}" stop-opacity="0.9"/></radialGradient><linearGradient id="a2" x1="0" y1="0" x2="0" y2="1" gradientUnits="objectBoundingBox" spreadMethod="pad"><stop offset="0%" stop-color="${PAPER}" stop-opacity="0.8"/><stop offset="100%" stop-color="${PAPER}" stop-opacity="0"/></linearGradient><clipPath id="a3" clipPathUnits="userSpaceOnUse"><circle cx="128" cy="128" r="100"/></clipPath></defs><g clip-path="url(#a3)"><circle cx="128" cy="128" r="100" fill="url(#a1)"/><ellipse cx="110" cy="80" rx="50" ry="26" fill="url(#a2)"/></g></svg>`; |
| 53 | expect(sanitize(deep)).toBe(deep); |
| 54 | // A gradient's transform is held to a transform's own grammar. |
| 55 | expect(sanitize(`<svg viewBox="0 0 8 8"><linearGradient id="g" gradientTransform="url(x)"/><circle r="1"/></svg>`)).toContain(`<linearGradient id="g"/>`); |
| 56 | }); |
| 57 | |
| 58 | test("a title's text is text, and markup in values is refused", () => { |
| 59 | const svg = sanitize(`<svg viewBox="0 0 8 8"><title>A & B <b>bold</b></title><circle cx="4" cy="4" r="2" fill=""><script>"/></svg>`)!; |
| 60 | // An element inside a title goes with its content; the entity stays an entity. |
| 61 | expect(svg).toContain("<title>A & B </title>"); |
| 62 | expect(sanitize(`<svg viewBox="0 0 8 8"><title>R&D</title><circle r="1"/></svg>`)).toContain("<title>R&D</title>"); |
| 63 | expect(svg).toContain(`<circle cx="4" cy="4" r="2"/>`); |
| 64 | }); |
| 65 | |
| 66 | test("no SVG, an empty one, a second root or a huge one is not kept", () => { |
| 67 | expect(sanitize("just words")).toBeUndefined(); |
| 68 | expect(sanitize(`<svg viewBox="0 0 8 8"><title>Nothing</title></svg>`)).toBeUndefined(); |
| 69 | expect(sanitize(`<svg><circle r="2"/></svg>`)).toBeUndefined(); // no viewBox |
| 70 | expect(sanitize(`<svg viewBox="0 0 8 8"><svg viewBox="0 0 8 8"><circle r="1"/></svg></svg>`)).toBeUndefined(); |
| 71 | const huge = `<svg viewBox="0 0 8 8">${`<circle cx="1" cy="1" r="1"/>`.repeat(2000)}</svg>`; |
| 72 | expect(sanitize(huge)).toBeUndefined(); |
| 73 | // Unclosed tags are closed. |
| 74 | expect(sanitize(`<svg viewBox="0 0 8 8"><g><circle r="1"/>`)).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><g><circle r="1"/></g></svg>`); |
| 75 | }); |
| 76 | |
| 77 | test("the reply's fenced blocks are read, bad ones dropped, at most four kept", () => { |
| 78 | const reply = ["```svg", ok, "```", "```svg", "<svg>nope</svg>", "```", ...Array(5).fill(["```svg", ok, "```"]).flat()].join("\n"); |
| 79 | expect(parseReply(reply)).toEqual([ok, ok, ok, ok]); |
| 80 | // No fences: the bare SVGs in the text. |
| 81 | expect(parseReply(`Here: ${ok} and ${ok}`)).toEqual([ok, ok]); |
| 82 | expect(parseReply("I can't draw that.")).toEqual([]); |
| 83 | }); |
| 84 | |
| 85 | test("a palette swaps ink and glow, even when one palette's ink is another's placeholder", () => { |
| 86 | const neon = inPalette(ok, "neon"); |
| 87 | expect(neon).toContain(`stroke="${PALETTES.neon!.ink}"`); |
| 88 | expect(neon).toContain(`fill="${PALETTES.neon!.glow}"`); |
| 89 | expect(neon).not.toContain(INK); |
| 90 | expect(inPalette(ok, "sage")).toBe(ok); |
| 91 | expect(inPalette(ok.toUpperCase().replace("<SVG", "<svg"), "mono")).toContain(PALETTES.mono!.ink); |
| 92 | // Paper is the palette's ground, swapped once even where a ground is some placeholder. |
| 93 | const papered = `<svg viewBox="0 0 8 8"><stop stop-color="${PAPER}"/><circle r="1" fill="${INK}" stroke="${GLOW}"/></svg>`; |
| 94 | for (const [key, p] of Object.entries(PALETTES)) |
| 95 | expect([key, inPalette(papered, key as keyof typeof PALETTES)]).toEqual([ |
| 96 | key, |
| 97 | `<svg viewBox="0 0 8 8"><stop stop-color="${p.ground}"/><circle r="1" fill="${p.ink}" stroke="${p.glow}"/></svg>`, |
| 98 | ]); |
| 99 | }); |
| 100 | |
| 101 | test("the ask names the brief, the kind, the colours and the count", () => { |
| 102 | const text = ask("a fox", "badge"); |
| 103 | for (const part of ["a fox", "badge", INK, GLOW, PAPER, "4 distinct", "viewBox=\"0 0 256 256\"", "radialGradient", "url(#"]) |
| 104 | expect([part, text.includes(part)]).toEqual([part, true]); |
| 105 | }); |
| 106 | |
| 107 | test("Claude is started with no tools, and with nothing of ours but its token and the way out", () => { |
| 108 | const args = drawArgs(); |
| 109 | expect(args.slice(args.indexOf("--tools"), args.indexOf("--tools") + 2)).toEqual(["--tools", ""]); |
| 110 | expect(args).toContain("--no-session-persistence"); |
| 111 | const env = claudeEnv("tok", "/data/claude", { |
| 112 | PATH: "/bin", |
| 113 | HTTPS_PROXY: "http://127.0.0.1:3128", |
| 114 | FORGE_APP_IDENTITY_KEY: "secret", |
| 115 | SOMETHING_ELSE: "x", |
| 116 | }); |
| 117 | expect(env).toMatchObject({ CLAUDE_CODE_OAUTH_TOKEN: "tok", CLAUDE_CONFIG_DIR: "/data/claude", HTTPS_PROXY: "http://127.0.0.1:3128" }); |
| 118 | expect(env.FORGE_APP_IDENTITY_KEY).toBeUndefined(); |
| 119 | expect(env.SOMETHING_ELSE).toBeUndefined(); |
| 120 | }); |
| 121 | |
| 122 | test("Claude's answer is read, and a refusal or junk is a sentence, not a crash", async () => { |
| 123 | const answer = JSON.stringify({ type: "result", is_error: false, result: "```svg\n" + ok + "\n```" }); |
| 124 | expect(resultText(answer)).toEqual({ ok: true, text: "```svg\n" + ok + "\n```" }); |
| 125 | expect(resultText("not json").ok).toBe(false); |
| 126 | expect(resultText(JSON.stringify({ is_error: true, result: "Not logged in" }))).toEqual({ ok: false, why: "Claude stopped: Not logged in" }); |
| 127 | const seen: string[][] = []; |
| 128 | const draw = claudeDrawer(async (args) => { |
| 129 | seen.push([...args]); |
| 130 | return answer; |
| 131 | }); |
| 132 | expect(await draw("a fox", "mark")).toEqual([ok]); |
| 133 | expect(seen[0]).toEqual(drawArgs()); |
| 134 | const empty = claudeDrawer(async () => JSON.stringify({ result: "Sorry, no." })); |
| 135 | await expect(empty("a fox", "mark")).rejects.toThrow("no drawing in it passed"); |
| 136 | }); |