| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 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, 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 |
|
| 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 url(#…), no colour functions, only a transform's own", () => { |
| 42 |
const svg = sanitize(`<svg viewBox="0 0 8 8"><circle r="1" fill="url(#g)" stroke="rgb(1,2,3)" transform="rotate(45 4 4)"/></svg>`)!; |
| 43 |
expect(svg).toContain(`<circle r="1" transform="rotate(45 4 4)"/>`); |
| 44 |
}); |
| 45 |
|
| 46 |
test("a title's text is text, and markup in values is refused", () => { |
| 47 |
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>`)!; |
| 48 |
|
| 49 |
expect(svg).toContain("<title>A & B </title>"); |
| 50 |
expect(sanitize(`<svg viewBox="0 0 8 8"><title>R&D</title><circle r="1"/></svg>`)).toContain("<title>R&D</title>"); |
| 51 |
expect(svg).toContain(`<circle cx="4" cy="4" r="2"/>`); |
| 52 |
}); |
| 53 |
|
| 54 |
test("no SVG, an empty one, a second root or a huge one is not kept", () => { |
| 55 |
expect(sanitize("just words")).toBeUndefined(); |
| 56 |
expect(sanitize(`<svg viewBox="0 0 8 8"><title>Nothing</title></svg>`)).toBeUndefined(); |
| 57 |
expect(sanitize(`<svg><circle r="2"/></svg>`)).toBeUndefined(); |
| 58 |
expect(sanitize(`<svg viewBox="0 0 8 8"><svg viewBox="0 0 8 8"><circle r="1"/></svg></svg>`)).toBeUndefined(); |
| 59 |
const huge = `<svg viewBox="0 0 8 8">${`<circle cx="1" cy="1" r="1"/>`.repeat(2000)}</svg>`; |
| 60 |
expect(sanitize(huge)).toBeUndefined(); |
| 61 |
|
| 62 |
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>`); |
| 63 |
}); |
| 64 |
|
| 65 |
test("the reply's fenced blocks are read, bad ones dropped, at most four kept", () => { |
| 66 |
const reply = ["```svg", ok, "```", "```svg", "<svg>nope</svg>", "```", ...Array(5).fill(["```svg", ok, "```"]).flat()].join("\n"); |
| 67 |
expect(parseReply(reply)).toEqual([ok, ok, ok, ok]); |
| 68 |
|
| 69 |
expect(parseReply(`Here: ${ok} and ${ok}`)).toEqual([ok, ok]); |
| 70 |
expect(parseReply("I can't draw that.")).toEqual([]); |
| 71 |
}); |
| 72 |
|
| 73 |
test("a palette swaps ink and glow, even when one palette's ink is another's placeholder", () => { |
| 74 |
const neon = inPalette(ok, "neon"); |
| 75 |
expect(neon).toContain(`stroke="${PALETTES.neon!.ink}"`); |
| 76 |
expect(neon).toContain(`fill="${PALETTES.neon!.glow}"`); |
| 77 |
expect(neon).not.toContain(INK); |
| 78 |
expect(inPalette(ok, "sage")).toBe(ok); |
| 79 |
expect(inPalette(ok.toUpperCase().replace("<SVG", "<svg"), "mono")).toContain(PALETTES.mono!.ink); |
| 80 |
}); |
| 81 |
|
| 82 |
test("the ask names the brief, the kind, the colours and the count", () => { |
| 83 |
const text = ask("a fox", "badge"); |
| 84 |
for (const part of ["a fox", "badge", INK, GLOW, "4 distinct", "viewBox=\"0 0 64 64\""]) |
| 85 |
expect([part, text.includes(part)]).toEqual([part, true]); |
| 86 |
}); |
| 87 |
|
| 88 |
test("Claude is started with no tools, and with nothing of ours but its token and the way out", () => { |
| 89 |
const args = drawArgs(); |
| 90 |
expect(args.slice(args.indexOf("--tools"), args.indexOf("--tools") + 2)).toEqual(["--tools", ""]); |
| 91 |
expect(args).toContain("--no-session-persistence"); |
| 92 |
const env = claudeEnv("tok", "/data/claude", { |
| 93 |
PATH: "/bin", |
| 94 |
HTTPS_PROXY: "http://127.0.0.1:3128", |
| 95 |
FORGE_APP_IDENTITY_KEY: "secret", |
| 96 |
SOMETHING_ELSE: "x", |
| 97 |
}); |
| 98 |
expect(env).toMatchObject({ CLAUDE_CODE_OAUTH_TOKEN: "tok", CLAUDE_CONFIG_DIR: "/data/claude", HTTPS_PROXY: "http://127.0.0.1:3128" }); |
| 99 |
expect(env.FORGE_APP_IDENTITY_KEY).toBeUndefined(); |
| 100 |
expect(env.SOMETHING_ELSE).toBeUndefined(); |
| 101 |
}); |
| 102 |
|
| 103 |
test("Claude's answer is read, and a refusal or junk is a sentence, not a crash", async () => { |
| 104 |
const answer = JSON.stringify({ type: "result", is_error: false, result: "```svg\n" + ok + "\n```" }); |
| 105 |
expect(resultText(answer)).toEqual({ ok: true, text: "```svg\n" + ok + "\n```" }); |
| 106 |
expect(resultText("not json").ok).toBe(false); |
| 107 |
expect(resultText(JSON.stringify({ is_error: true, result: "Not logged in" }))).toEqual({ ok: false, why: "Claude stopped: Not logged in" }); |
| 108 |
const seen: string[][] = []; |
| 109 |
const draw = claudeDrawer(async (args) => { |
| 110 |
seen.push([...args]); |
| 111 |
return answer; |
| 112 |
}); |
| 113 |
expect(await draw("a fox", "mark")).toEqual([ok]); |
| 114 |
expect(seen[0]).toEqual(drawArgs()); |
| 115 |
const empty = claudeDrawer(async () => JSON.stringify({ result: "Sorry, no." })); |
| 116 |
await expect(empty("a fox", "mark")).rejects.toThrow("no drawing in it passed"); |
| 117 |
}); |