wren/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine
Files draw
117 lines · 6.4 KB · ~1.9k tok Raw History
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, 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 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 &amp; B <b>bold</b></title><circle cx="4" cy="4" r="2" fill="&quot;&gt;&lt;script&gt;"/></svg>`)!;
48
  // An element inside a title goes with its content; the entity stays an entity.
49
  expect(svg).toContain("<title>A &amp; B </title>");
50
  expect(sanitize(`<svg viewBox="0 0 8 8"><title>R&D</title><circle r="1"/></svg>`)).toContain("<title>R&amp;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(); // no viewBox
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
  // Unclosed tags are closed.
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
  // No fences: the bare SVGs in the text.
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
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts