1import * as NodeVM from "node:vm";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
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";
17import { T3_CODE_DARK_THEME_COLORS, T3_CODE_LIGHT_THEME_COLORS } from "./themePalettes.ts";
18import { htmlRenderFromToolItem } from "./toolOutput.ts";
19
20const reference = { attachmentId: "thread-abc-123.html", title: "Chart", height: 420 };
21
22describe("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
189describe("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
221describe("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
244describe("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
267describe("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
278describe("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
293describe("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
302describe("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
329describe("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});