| 1 | import * as NodeVM from "node:vm"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | |
| 4 | import { |
| 5 | HTML_RENDER_MAX_HEIGHT, |
| 6 | htmlRenderFrameHeight, |
| 7 | htmlRenderReferencesEqual, |
| 8 | htmlRenderTheme, |
| 9 | htmlRenderThemeFragment, |
| 10 | injectHtmlRenderBootstrap, |
| 11 | htmlRenderThemeMessage, |
| 12 | readHtmlRenderContentSize, |
| 13 | readHtmlRenderKeyDown, |
| 14 | readHtmlRenderLinkRequest, |
| 15 | readHtmlRenderReference, |
| 16 | } from "./htmlRender.ts"; |
| 17 | import { T3_CODE_DARK_THEME_COLORS, T3_CODE_LIGHT_THEME_COLORS } from "./themePalettes.ts"; |
| 18 | import { htmlRenderFromToolItem } from "./toolOutput.ts"; |
| 19 | |
| 20 | const reference = { attachmentId: "thread-abc-123.html", title: "Chart", height: 420 }; |
| 21 | |
| 22 | describe("injectHtmlRenderBootstrap", () => { |
| 23 | it("tracks descendant resizing and mutations while preserving the page's authored width", () => { |
| 24 | const listeners = new Map<string, () => void>(); |
| 25 | const style = new Map([ |
| 26 | ["width", "640px"], |
| 27 | ["priority", "important"], |
| 28 | ]); |
| 29 | let intrinsicWidth = 920; |
| 30 | const sizes: unknown[] = []; |
| 31 | const observed = new Set<unknown>(); |
| 32 | let resize: (() => void) | undefined; |
| 33 | let mutate: |
| 34 | | ((records: { readonly addedNodes: unknown[]; readonly removedNodes: unknown[] }[]) => void) |
| 35 | | undefined; |
| 36 | const figure = { nodeType: 1, querySelectorAll: () => [] }; |
| 37 | const descendants = [figure]; |
| 38 | const body = { |
| 39 | nodeType: 1, |
| 40 | querySelectorAll: () => descendants, |
| 41 | get scrollWidth() { |
| 42 | return style.get("width") === "max-content" ? intrinsicWidth : 640; |
| 43 | }, |
| 44 | }; |
| 45 | class ResizeObserver { |
| 46 | constructor(callback: () => void) { |
| 47 | resize = callback; |
| 48 | } |
| 49 | observe(target: unknown) { |
| 50 | observed.add(target); |
| 51 | } |
| 52 | unobserve(target: unknown) { |
| 53 | observed.delete(target); |
| 54 | } |
| 55 | } |
| 56 | class MutationObserver { |
| 57 | constructor(callback: NonNullable<typeof mutate>) { |
| 58 | mutate = callback; |
| 59 | } |
| 60 | observe(target: unknown, options: unknown) { |
| 61 | expect(target).toBe(body); |
| 62 | expect(options).toEqual({ |
| 63 | subtree: true, |
| 64 | childList: true, |
| 65 | attributes: true, |
| 66 | characterData: true, |
| 67 | }); |
| 68 | } |
| 69 | } |
| 70 | const root = { |
| 71 | clientHeight: 100, |
| 72 | scrollHeight: 240, |
| 73 | getBoundingClientRect: () => ({ |
| 74 | width: style.get("width") === "max-content" ? intrinsicWidth : 640, |
| 75 | height: 240, |
| 76 | }), |
| 77 | style: { |
| 78 | getPropertyValue: (property: string) => style.get(property), |
| 79 | getPropertyPriority: () => style.get("priority"), |
| 80 | setProperty: (property: string, value: string, priority: string) => { |
| 81 | style.set(property, value); |
| 82 | style.set("priority", priority); |
| 83 | }, |
| 84 | removeProperty: (property: string) => style.delete(property), |
| 85 | }, |
| 86 | }; |
| 87 | const bootstrap = |
| 88 | injectHtmlRenderBootstrap("<p>Figure</p>").match(/<script>(.*?)<\/script>/s)![1]; |
| 89 | NodeVM.runInNewContext(bootstrap!, { |
| 90 | document: { |
| 91 | getElementById: () => ({ textContent: "" }), |
| 92 | documentElement: root, |
| 93 | body, |
| 94 | addEventListener: (event: string, listener: () => void) => listeners.set(event, listener), |
| 95 | }, |
| 96 | location: { hash: "" }, |
| 97 | ResizeObserver, |
| 98 | MutationObserver, |
| 99 | window: { |
| 100 | ResizeObserver, |
| 101 | MutationObserver, |
| 102 | addEventListener: (event: string, listener: () => void) => listeners.set(event, listener), |
| 103 | parent: { |
| 104 | postMessage: (data: unknown) => { |
| 105 | expect(style.get("width")).toBe("640px"); |
| 106 | expect(style.get("priority")).toBe("important"); |
| 107 | sizes.push(readHtmlRenderContentSize(data)); |
| 108 | }, |
| 109 | }, |
| 110 | }, |
| 111 | }); |
| 112 | |
| 113 | listeners.get("DOMContentLoaded")!(); |
| 114 | expect(observed).toEqual(new Set([root, body, figure])); |
| 115 | listeners.get("load")!(); |
| 116 | intrinsicWidth = 1100; |
| 117 | resize!(); |
| 118 | intrinsicWidth = 420; |
| 119 | resize!(); |
| 120 | const replacement = { nodeType: 1, querySelectorAll: () => [] }; |
| 121 | descendants.splice(0, 1, replacement); |
| 122 | intrinsicWidth = 880; |
| 123 | mutate!([{ addedNodes: [replacement], removedNodes: [figure] }]); |
| 124 | expect(observed).toEqual(new Set([root, body, replacement])); |
| 125 | expect(sizes).toEqual([ |
| 126 | { width: 920, height: 240 }, |
| 127 | { width: 1100, height: 240 }, |
| 128 | { width: 420, height: 240 }, |
| 129 | { width: 880, height: 240 }, |
| 130 | ]); |
| 131 | }); |
| 132 | |
| 133 | it("puts the theme ahead of the page's own head content", () => { |
| 134 | const html = |
| 135 | "<!doctype html><html><head><style>:root{--background:red}</style></head><body>x</body></html>"; |
| 136 | const injected = injectHtmlRenderBootstrap(html); |
| 137 | const themeAt = injected.indexOf('<style id="t3-theme">'); |
| 138 | expect(themeAt).toBeGreaterThan(injected.indexOf("<head>")); |
| 139 | expect(themeAt).toBeLessThan(injected.indexOf(":root{--background:red}")); |
| 140 | expect(injected).toContain('<meta charset="utf-8">'); |
| 141 | expect(injected).toContain('name="viewport"'); |
| 142 | }); |
| 143 | |
| 144 | it("wraps fragments without a head and keeps existing meta tags", () => { |
| 145 | const fragment = |
| 146 | '<meta charset="utf-8"><meta name="viewport" content="width=device-width"><p>hi</p>'; |
| 147 | const injected = injectHtmlRenderBootstrap(fragment); |
| 148 | expect(injected.startsWith("<!doctype html><head>")).toBe(true); |
| 149 | expect(injected.match(/charset/g)).toHaveLength(1); |
| 150 | expect(injected.match(/name="viewport"/g)).toHaveLength(1); |
| 151 | expect(injected.endsWith("<p>hi</p>")).toBe(true); |
| 152 | }); |
| 153 | |
| 154 | it.each(["textarea", "title", "xmp", "iframe", "noembed", "noframes", "noscript", "plaintext"])( |
| 155 | "keeps the bootstrap outside %s content", |
| 156 | (tag) => { |
| 157 | const fragment = `<${tag}><head><meta name="viewport"></head></${tag}>`; |
| 158 | const injected = injectHtmlRenderBootstrap(fragment); |
| 159 | expect(injected.startsWith("<!doctype html><head>")).toBe(true); |
| 160 | expect(injected.indexOf('<style id="t3-theme">')).toBeLessThan(injected.indexOf(`<${tag}>`)); |
| 161 | expect(injected).toContain('<meta name="viewport" content="width=device-width'); |
| 162 | expect(injected.endsWith(fragment)).toBe(true); |
| 163 | }, |
| 164 | ); |
| 165 | |
| 166 | it.each([ |
| 167 | '<template><head><meta name="viewport"></head></template>', |
| 168 | '<template><template>inner</template><head><meta name="viewport"></head></template>', |
| 169 | ])("keeps the bootstrap outside inert template content: %s", (fragment) => { |
| 170 | const injected = injectHtmlRenderBootstrap(fragment); |
| 171 | expect(injected.startsWith("<!doctype html><head>")).toBe(true); |
| 172 | expect(injected.indexOf('<style id="t3-theme">')).toBeLessThan(injected.indexOf("<template>")); |
| 173 | expect(injected).toContain('<meta name="viewport" content="width=device-width'); |
| 174 | expect(injected.endsWith(fragment)).toBe(true); |
| 175 | }); |
| 176 | |
| 177 | it("ignores tags written inside comments and scripts", () => { |
| 178 | const html = |
| 179 | '<!-- copy <head> and <meta name="viewport"> here --><html><head>' + |
| 180 | "<script>const tag = '<meta name=\"viewport\">';</script></head><body>x</body></html>"; |
| 181 | const injected = injectHtmlRenderBootstrap(html); |
| 182 | expect(injected.indexOf('<style id="t3-theme">')).toBeGreaterThan( |
| 183 | injected.indexOf("<html><head>"), |
| 184 | ); |
| 185 | expect(injected).toContain('<meta name="viewport" content="width=device-width'); |
| 186 | }); |
| 187 | }); |
| 188 | |
| 189 | describe("readHtmlRenderLinkRequest", () => { |
| 190 | it("accepts only http(s) URLs in an MCP Apps ui/open-link request", () => { |
| 191 | const link = (url: unknown, modifiers = {}) => ({ |
| 192 | jsonrpc: "2.0", |
| 193 | id: 1, |
| 194 | method: "ui/open-link", |
| 195 | params: { url, ...modifiers }, |
| 196 | }); |
| 197 | expect(readHtmlRenderLinkRequest(link("https://example.com/a"))).toEqual({ |
| 198 | id: 1, |
| 199 | url: "https://example.com/a", |
| 200 | metaKey: false, |
| 201 | ctrlKey: false, |
| 202 | }); |
| 203 | expect( |
| 204 | readHtmlRenderLinkRequest(link("https://example.com/a", { metaKey: true, ctrlKey: "yes" })), |
| 205 | ).toEqual({ id: 1, url: "https://example.com/a", metaKey: true, ctrlKey: false }); |
| 206 | expect(readHtmlRenderLinkRequest(link("javascript:alert(1)"))).toBeUndefined(); |
| 207 | expect(readHtmlRenderLinkRequest(link("file:///etc/passwd"))).toBeUndefined(); |
| 208 | expect( |
| 209 | readHtmlRenderLinkRequest({ |
| 210 | jsonrpc: "2.0", |
| 211 | method: "ui/open-link", |
| 212 | params: { url: "https://example.com" }, |
| 213 | }), |
| 214 | ).toBeUndefined(); |
| 215 | expect( |
| 216 | readHtmlRenderLinkRequest({ type: "t3-html-render-link", url: "https://example.com" }), |
| 217 | ).toBeUndefined(); |
| 218 | }); |
| 219 | }); |
| 220 | |
| 221 | describe("readHtmlRenderKeyDown", () => { |
| 222 | it("reads the key of a keydown notification and nothing else", () => { |
| 223 | const notification = (params: unknown) => ({ |
| 224 | jsonrpc: "2.0", |
| 225 | method: "t3/notifications/keydown", |
| 226 | params, |
| 227 | }); |
| 228 | expect(readHtmlRenderKeyDown(notification({ key: "k", code: "KeyK", metaKey: true }))).toEqual({ |
| 229 | key: "k", |
| 230 | code: "KeyK", |
| 231 | metaKey: true, |
| 232 | ctrlKey: false, |
| 233 | shiftKey: false, |
| 234 | altKey: false, |
| 235 | repeat: false, |
| 236 | }); |
| 237 | expect(readHtmlRenderKeyDown(notification({ key: 1, code: "KeyK" }))).toBeUndefined(); |
| 238 | expect( |
| 239 | readHtmlRenderKeyDown({ jsonrpc: "2.0", method: "ui/open-link", params: { key: "k" } }), |
| 240 | ).toBeUndefined(); |
| 241 | }); |
| 242 | }); |
| 243 | |
| 244 | describe("readHtmlRenderContentSize", () => { |
| 245 | it("reads positive dimensions and retains height-only notifications from existing renders", () => { |
| 246 | const notification = (params: unknown) => ({ |
| 247 | jsonrpc: "2.0", |
| 248 | method: "ui/notifications/size-changed", |
| 249 | params, |
| 250 | }); |
| 251 | expect(readHtmlRenderContentSize(notification({ height: 412 }))).toEqual({ height: 412 }); |
| 252 | expect(readHtmlRenderContentSize(notification({ height: 412, width: 920 }))).toEqual({ |
| 253 | height: 412, |
| 254 | width: 920, |
| 255 | }); |
| 256 | expect(readHtmlRenderContentSize(notification({ height: 412, width: Infinity }))).toEqual({ |
| 257 | height: 412, |
| 258 | }); |
| 259 | expect(readHtmlRenderContentSize(notification({ height: "412" }))).toBe(undefined); |
| 260 | expect(readHtmlRenderContentSize(notification({ height: 0 }))).toBe(undefined); |
| 261 | expect(readHtmlRenderContentSize({ ...notification({ height: 412 }), method: "x" })).toBe( |
| 262 | undefined, |
| 263 | ); |
| 264 | }); |
| 265 | }); |
| 266 | |
| 267 | describe("htmlRenderThemeMessage", () => { |
| 268 | it("is an MCP Apps host-context-changed notification carrying the theme variables", () => { |
| 269 | const theme = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); |
| 270 | expect(htmlRenderThemeMessage(theme)).toEqual({ |
| 271 | jsonrpc: "2.0", |
| 272 | method: "ui/notifications/host-context-changed", |
| 273 | params: { theme: "dark", styles: { variables: theme.variables } }, |
| 274 | }); |
| 275 | }); |
| 276 | }); |
| 277 | |
| 278 | describe("htmlRenderTheme", () => { |
| 279 | it("exposes the brand accent as --accent and keeps the fragment decodable", () => { |
| 280 | const theme = htmlRenderTheme(T3_CODE_LIGHT_THEME_COLORS, "light"); |
| 281 | expect(theme.variables["--accent"]).toBe(T3_CODE_LIGHT_THEME_COLORS.accent); |
| 282 | expect(theme.variables["--chart-1"]).toBe(T3_CODE_LIGHT_THEME_COLORS.accent); |
| 283 | expect(theme.variables["--chart-6"]).toBeDefined(); |
| 284 | const fragment = htmlRenderThemeFragment(theme); |
| 285 | expect(JSON.parse(decodeURIComponent(fragment.slice("#t3-theme=".length)))).toEqual(theme); |
| 286 | expect(fragment).not.toContain("&"); |
| 287 | expect(htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark").variables["--background"]).toBe( |
| 288 | T3_CODE_DARK_THEME_COLORS.canvas, |
| 289 | ); |
| 290 | }); |
| 291 | }); |
| 292 | |
| 293 | describe("readHtmlRenderReference", () => { |
| 294 | it("clamps height and rejects malformed references", () => { |
| 295 | expect(readHtmlRenderReference({ ...reference, height: 99_999 })?.height).toBe(2000); |
| 296 | expect(readHtmlRenderReference({ ...reference, title: " " })?.title).toBe("HTML"); |
| 297 | expect(readHtmlRenderReference({ ...reference, attachmentId: 4 })).toBeUndefined(); |
| 298 | expect(readHtmlRenderReference({ ...reference, height: Number.NaN })).toBeUndefined(); |
| 299 | }); |
| 300 | }); |
| 301 | |
| 302 | describe("htmlRenderFromToolItem", () => { |
| 303 | const result = { htmlRender: reference, message: "Rendered above your reply." }; |
| 304 | |
| 305 | it("reads the reference from each provider's result envelope", () => { |
| 306 | for (const [toolName, output] of [ |
| 307 | ["mcp__t3-code__html_render", [{ type: "text", text: JSON.stringify(result) }]], |
| 308 | ["t3-code.html_render", { structuredContent: result, content: [] }], |
| 309 | ["t3-code-thread_1_html_render", JSON.stringify(result)], |
| 310 | ["html_render", result], |
| 311 | ] as const) { |
| 312 | expect(htmlRenderFromToolItem({ toolName, output })).toEqual(reference); |
| 313 | } |
| 314 | }); |
| 315 | |
| 316 | it("ignores other tools and failed calls", () => { |
| 317 | expect(htmlRenderFromToolItem({ toolName: "mcp__t3-code__html_preview", output: result })).toBe( |
| 318 | undefined, |
| 319 | ); |
| 320 | expect(htmlRenderFromToolItem({ toolName: "mcp__other__html_render", output: result })).toBe( |
| 321 | undefined, |
| 322 | ); |
| 323 | expect( |
| 324 | htmlRenderFromToolItem({ toolName: "html_render", output: { ...result, isError: true } }), |
| 325 | ).toBeUndefined(); |
| 326 | }); |
| 327 | }); |
| 328 | |
| 329 | describe("htmlRenderFrameHeight", () => { |
| 330 | const measured = readHtmlRenderReference({ |
| 331 | ...reference, |
| 332 | height: 1500, |
| 333 | heights: [ |
| 334 | [728, 1403], |
| 335 | [390, 1290], |
| 336 | [1000, 1660], |
| 337 | ], |
| 338 | })!; |
| 339 | |
| 340 | it("takes the taller neighbor between measured widths and holds the ends", () => { |
| 341 | expect(measured.heights?.map(([width]) => width)).toEqual([390, 728, 1000]); |
| 342 | expect(htmlRenderFrameHeight(measured, 728)).toBe(1403); |
| 343 | expect(htmlRenderFrameHeight(measured, 559)).toBe(1403); |
| 344 | expect(htmlRenderFrameHeight(measured, 320)).toBe(1290); |
| 345 | }); |
| 346 | |
| 347 | it("takes the taller layout when a breakpoint falls between measured widths", () => { |
| 348 | // 900px tall below a 600px media query, 450px above it. |
| 349 | const responsive = readHtmlRenderReference({ |
| 350 | ...reference, |
| 351 | height: 2000, |
| 352 | heights: [ |
| 353 | [520, 900], |
| 354 | [640, 450], |
| 355 | ], |
| 356 | })!; |
| 357 | expect(htmlRenderFrameHeight(responsive, 590)).toBe(900); |
| 358 | expect(htmlRenderFrameHeight(responsive, 640)).toBe(450); |
| 359 | }); |
| 360 | |
| 361 | it("fits a page the client lays out taller than the server measured", () => { |
| 362 | // The agent passed contentHeight at the column width, so the page should never scroll. |
| 363 | const fitted = { ...measured, height: 1403 }; |
| 364 | expect(htmlRenderFrameHeight(fitted, 728, 1415)).toBe(1415); |
| 365 | expect(htmlRenderFrameHeight(fitted, 1400)).toBe(1660); |
| 366 | expect(htmlRenderFrameHeight(fitted, 728, 5000)).toBe(HTML_RENDER_MAX_HEIGHT); |
| 367 | }); |
| 368 | |
| 369 | it("keeps the agent's height when it asked for a scrolling frame or the page is unmeasured", () => { |
| 370 | const scrolling = { ...measured, height: 600 }; |
| 371 | expect(htmlRenderFrameHeight(scrolling, 728, 1415)).toBe(600); |
| 372 | expect(htmlRenderFrameHeight(scrolling, 1400)).toBe(600); |
| 373 | expect(htmlRenderFrameHeight(reference, 728)).toBe(reference.height); |
| 374 | expect(htmlRenderFrameHeight(reference, 728, 900)).toBe(reference.height); |
| 375 | expect(htmlRenderFrameHeight(reference, 728, 300)).toBe(300); |
| 376 | }); |
| 377 | |
| 378 | it("drops a malformed table and compares tables by value", () => { |
| 379 | expect(readHtmlRenderReference({ ...reference, heights: [[728, "x"]] })?.heights).toBe( |
| 380 | undefined, |
| 381 | ); |
| 382 | const copy = readHtmlRenderReference(JSON.parse(JSON.stringify(measured)))!; |
| 383 | expect(htmlRenderReferencesEqual(measured, copy)).toBe(true); |
| 384 | expect(htmlRenderReferencesEqual(measured, { ...copy, heights: [[390, 1290]] })).toBe(false); |
| 385 | }); |
| 386 | }); |