Skip to content
Source
Sign inSign up

htmlRender.test.ts

176 lines7.8 KB

1import { describe, expect, it } from "vite-plus/test";
2​
3import {
4 injectHtmlRenderBootstrap,
5 readHtmlRenderImageRequest,
6 readHtmlRenderLinkRequest,
7} from "./htmlRender.ts";
8import { htmlRenderFromToolItem } from "./toolOutput.ts";
9​
10const reference = { attachmentId: "thread-abc-123.html", title: "Chart", height: 420 };
11​
12describe("injectHtmlRenderBootstrap", () => {
13 it("puts the theme ahead of the page's own head content", () => {
14 const html =
15 "<!doctype html><html><head><style>:root{--background:red}</style></head><body>x</body></html>";
16 const injected = injectHtmlRenderBootstrap(html);
17 const themeAt = injected.indexOf('<style id="t3-theme">');
18 expect(themeAt).toBeGreaterThan(injected.indexOf("<head>"));
19 expect(themeAt).toBeLessThan(injected.indexOf(":root{--background:red}"));
20 expect(injected).toContain('<meta charset="utf-8">');
21 expect(injected).toContain('name="viewport"');
22 });
23​
24 it.each([
25 "<html><head data-condition='x > 0'>",
26 '<html><head data-condition="x > 0" data-label=\'it "works"\'>',
27 "<html data-condition='x > 0'>",
28 '<html data-condition="x > 0" data-label="it \'works\'">',
29 ])("keeps the bootstrap after the complete opening tag: %s", (opening) => {
30 const prefix = `<!doctype html>${opening}`;
31 const head = opening.startsWith("<html><head") ? "" : "<head>";
32 const content = `${head ? "" : "<title>Report</title></head>"}<body><p>Report</p></body></html>`;
33 const injected = injectHtmlRenderBootstrap(prefix + content);
34 expect(injected.startsWith(`${prefix}${head}<meta charset="utf-8">`)).toBe(true);
35 expect(injected.indexOf('<style id="t3-theme">')).toBeGreaterThan(prefix.length);
36 expect(injected).toContain("<script>");
37 expect(injected.endsWith(content)).toBe(true);
38 });
39​
40 it("wraps fragments without a head and keeps existing meta tags", () => {
41 const fragment =
42 '<meta charset="utf-8"><meta name="viewport" content="width=device-width"><p>hi</p>';
43 const injected = injectHtmlRenderBootstrap(fragment);
44 expect(injected.startsWith("<!doctype html><head>")).toBe(true);
45 expect(injected.match(/charset/g)).toHaveLength(1);
46 expect(injected.match(/name="viewport"/g)).toHaveLength(1);
47 expect(injected.endsWith("<p>hi</p>")).toBe(true);
48 });
49​
50 it.each(["textarea", "iframe", "plaintext"])("keeps the bootstrap outside %s content", (tag) => {
51 const fragment = `<${tag}><head><meta name="viewport"></head></${tag}>`;
52 const injected = injectHtmlRenderBootstrap(fragment);
53 expect(injected.startsWith("<!doctype html><head>")).toBe(true);
54 expect(injected.indexOf('<style id="t3-theme">')).toBeLessThan(injected.indexOf(`<${tag}>`));
55 expect(injected).toContain('<meta name="viewport" content="width=device-width');
56 expect(injected.endsWith(fragment)).toBe(true);
57 });
58​
59 it.each([
60 '<template><head><meta name="viewport"></head></template>',
61 '<template><template>inner</template><head><meta name="viewport"></head></template>',
62 ])("keeps the bootstrap outside inert template content: %s", (fragment) => {
63 const injected = injectHtmlRenderBootstrap(fragment);
64 expect(injected.startsWith("<!doctype html><head>")).toBe(true);
65 expect(injected.indexOf('<style id="t3-theme">')).toBeLessThan(injected.indexOf("<template>"));
66 expect(injected).toContain('<meta name="viewport" content="width=device-width');
67 expect(injected.endsWith(fragment)).toBe(true);
68 });
69​
70 it("ignores tags written inside comments and scripts", () => {
71 const html =
72 '<!-- copy <head> and <meta name="viewport"> here --><html><head>' +
73 "<script>const tag = '<meta name=\"viewport\">';</script></head><body>x</body></html>";
74 const injected = injectHtmlRenderBootstrap(html);
75 expect(injected.indexOf('<style id="t3-theme">')).toBeGreaterThan(
76 injected.indexOf("<html><head>"),
77 );
78 expect(injected).toContain('<meta name="viewport" content="width=device-width');
79 });
80});
81​
82describe("readHtmlRenderLinkRequest", () => {
83 it("accepts only http(s) URLs in an MCP Apps ui/open-link request", () => {
84 const link = (url: unknown, modifiers = {}) => ({
85 jsonrpc: "2.0",
86 id: 1,
87 method: "ui/open-link",
88 params: { url, ...modifiers },
89 });
90 expect(readHtmlRenderLinkRequest(link("https://example.com/a"))).toEqual({
91 id: 1,
92 url: "https://example.com/a",
93 metaKey: false,
94 ctrlKey: false,
95 });
96 expect(
97 readHtmlRenderLinkRequest(link("https://example.com/a", { metaKey: true, ctrlKey: "yes" })),
98 ).toEqual({ id: 1, url: "https://example.com/a", metaKey: true, ctrlKey: false });
99 expect(readHtmlRenderLinkRequest(link("javascript:alert(1)"))).toBeUndefined();
100 expect(readHtmlRenderLinkRequest(link("file:///etc/passwd"))).toBeUndefined();
101 expect(
102 readHtmlRenderLinkRequest({
103 jsonrpc: "2.0",
104 method: "ui/open-link",
105 params: { url: "https://example.com" },
106 }),
107 ).toBeUndefined();
108 expect(
109 readHtmlRenderLinkRequest({ type: "t3-html-render-link", url: "https://example.com" }),
110 ).toBeUndefined();
111 });
112});
113​
114describe("readHtmlRenderImageRequest", () => {
115 it("accepts http(s) and inline image URLs in an open-image notification", () => {
116 const image = (params: unknown) => ({
117 jsonrpc: "2.0",
118 method: "t3/notifications/open-image",
119 params,
120 });
121 expect(
122 readHtmlRenderImageRequest(image({ src: "data:image/png;base64,AA", name: " Chart " })),
123 ).toEqual({ images: [{ src: "data:image/png;base64,AA", name: "Chart" }], index: 0 });
124 expect(readHtmlRenderImageRequest(image({ src: "https://example.com/a.png" }))).toEqual({
125 images: [{ src: "https://example.com/a.png", name: "Image" }],
126 index: 0,
127 });
128 const boundaryImage = readHtmlRenderImageRequest(
129 image({ src: "https://example.com/a.png", name: "a".repeat(199) + "😀" }),
130 );
131 expect(boundaryImage?.images[0]?.name.isWellFormed()).toBe(true);
132 expect(boundaryImage?.images[0]?.name).toBe("a".repeat(199));
133 expect(readHtmlRenderImageRequest(image({ src: "javascript:alert(1)" }))).toBeUndefined();
134 expect(readHtmlRenderImageRequest(image({ src: "data:text/html,<p>" }))).toBeUndefined();
135 expect(readHtmlRenderImageRequest(image({ src: "blob:null/1" }))).toBeUndefined();
136 });
137​
138 it("keeps the page's other images, dropping unsafe ones, around the clicked image", () => {
139 const b = { src: "https://example.com/b.png", name: "B" };
140 const params = {
141 ...b,
142 images: [{ src: "https://example.com/a.png", name: "A" }, { src: "blob:null/1" }, b, null],
143 index: 2,
144 };
145 const request = (overrides: object) =>
146 readHtmlRenderImageRequest({
147 jsonrpc: "2.0",
148 method: "t3/notifications/open-image",
149 params: { ...params, ...overrides },
150 });
151 expect(request({})).toEqual({
152 images: [{ src: "https://example.com/a.png", name: "A" }, b],
153 index: 1,
154 });
155 // An index that does not point at the clicked image falls back to that image alone.
156 expect(request({ index: 0 })).toEqual({ images: [b], index: 0 });
157 expect(request({ index: -1 })).toEqual({ images: [b], index: 0 });
158 });
159});
160​
161describe("htmlRenderFromToolItem", () => {
162 const result = { htmlRender: reference, message: "Rendered above your reply." };
163​
164 it("reads the reference from each provider's result envelope", () => {
165 for (const [toolName, output] of [
166 ["mcp__t3-code__html_render", [{ type: "text", text: JSON.stringify(result) }]],
167 ["t3-code.html_render", { structuredContent: result, content: [] }],
168 ["t3-code-thread_1_html_render", JSON.stringify(result)],
169 ["html_render", result],
170 // OpenCode 2 Code Mode reports a script's nested html_render call as one `execute` item.
171 ["execute", JSON.stringify(result)],
172 ] as const) {
173 expect(htmlRenderFromToolItem({ toolName, output })).toEqual(reference);
174 }
175 });
176});