1// @effect-diagnostics nodeBuiltinImport:off - plain local servers stand in for LAN services.
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { describe, expect, it } from "@effect/vitest";
4import { ThreadId } from "@t3tools/contracts";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import {
7 HTML_RENDER_MEASURE_FONTS,
8 HTML_RENDER_MEASURE_WIDTHS,
9 htmlRenderTheme,
10} from "@t3tools/shared/htmlRender";
11import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes";
12import * as Deferred from "effect/Deferred";
13import * as Effect from "effect/Effect";
14import * as Base64 from "effect/encoding/Base64";
15import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as Path from "effect/Path";
20import * as NodeDgram from "node:dgram";
21import * as NodeHttp from "node:http";
22import * as NodeURL from "node:url";
23
24import { resolveAttachmentPathById } from "../attachmentStore.ts";
25import * as ServerConfig from "../config.ts";
26import * as HtmlRender from "./HtmlRender.ts";
27import * as PreviewBrowser from "../preview/PreviewBrowser.ts";
28
29// Real-browser tests run only when this names a chrome-headless-shell, for
30// example one T3 installed under <T3 home>/tools/chrome-headless-shell.
31const TEST_BROWSER_ENV = "T3CODE_TEST_HEADLESS_SHELL";
32
33const layerHtmlRender = (
34 executable?: string,
35 installed: Effect.Effect<Option.Option<string>> = Effect.succeed(
36 Option.fromUndefinedOr(executable),
37 ),
38) =>
39 HtmlRender.layer.pipe(
40 Layer.provide(
41 Layer.succeed(
42 PreviewBrowser.PreviewBrowser,
43 PreviewBrowser.PreviewBrowser.of({
44 executable:
45 executable === undefined
46 ? Effect.die("This test has no preview browser.")
47 : Effect.succeed(executable),
48 installed,
49 }),
50 ),
51 ),
52 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-html-render-" })),
53 Layer.provideMerge(NodeServices.layer),
54 );
55const layerTest = layerHtmlRender();
56
57const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
58
59describe("HtmlRender", () => {
60 it.effect("inlines local images by absolute path and leaves URLs and relative paths alone", () =>
61 Effect.gen(function* () {
62 const fileSystem = yield* FileSystem.FileSystem;
63 const path = yield* Path.Path;
64 const htmlRender = yield* HtmlRender.HtmlRender;
65 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-images-" });
66 const png = path.join(directory, "shot.png");
67 const svg = path.join(directory, "logo.svg");
68 yield* fileSystem.writeFile(png, PNG_BYTES);
69 yield* fileSystem.writeFileString(svg, "<svg/>");
70 const kept = [
71 "https://example.com/a.png",
72 "//cdn.example.com/b.png",
73 "./c.png",
74 "data:image/png;base64,AAAA",
75 ];
76
77 const prepared = yield* htmlRender.prepare(
78 [
79 "<!doctype html><html><head><title>Shots</title></head><body>",
80 `<img src="${png}"><div style="background:url(${svg})"></div>`,
81 `<script>const shots = ['${png}', \`${svg}\`];</script>`,
82 ...kept.map((src) => `<img src="${src}">`),
83 "</body></html>",
84 ].join(""),
85 );
86
87 const pngUri = `data:image/png;base64,${Base64.encode(PNG_BYTES)}`;
88 const svgUri = `data:image/svg+xml;base64,${Base64.encode("<svg/>")}`;
89 expect(prepared).toContain(`<img src="${pngUri}">`);
90 expect(prepared).toContain(`url(${svgUri})`);
91 expect(prepared).toContain(`['${pngUri}', \`${svgUri}\`]`);
92 expect(prepared).not.toContain(directory);
93 for (const src of kept) expect(prepared).toContain(`<img src="${src}">`);
94 // The theme bootstrap opens the head, ahead of the page's own markup.
95 expect(prepared.indexOf("<head>")).toBeLessThan(prepared.indexOf('<style id="t3-theme">'));
96 expect(prepared.indexOf('<style id="t3-theme">')).toBeLessThan(prepared.indexOf("<title>"));
97 }).pipe(Effect.provide(layerTest)),
98 );
99
100 it.effect("inlines an SVG behind processing instructions and a doctype subset", () =>
101 Effect.gen(function* () {
102 const fileSystem = yield* FileSystem.FileSystem;
103 const path = yield* Path.Path;
104 const htmlRender = yield* HtmlRender.HtmlRender;
105 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-images-" });
106 const svg = path.join(directory, "styled.svg");
107 const source = [
108 '<?xml version="1.0"?>',
109 '<?xml-stylesheet href="theme.css"?>',
110 '<!DOCTYPE svg [ <!ENTITY fill "red"> ]>',
111 '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"/>',
112 ].join("\n");
113 yield* fileSystem.writeFileString(svg, source);
114
115 const prepared = yield* htmlRender.prepare(`<img src="${svg}">`);
116
117 expect(prepared).toContain(`data:image/svg+xml;base64,${Base64.encode(source)}`);
118 }).pipe(Effect.provide(layerTest)),
119 );
120
121 it.effect("lists every local image it cannot read", () =>
122 Effect.gen(function* () {
123 const fileSystem = yield* FileSystem.FileSystem;
124 const path = yield* Path.Path;
125 const htmlRender = yield* HtmlRender.HtmlRender;
126 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-images-" });
127 const folder = path.join(directory, "folder.png");
128 yield* fileSystem.makeDirectory(folder);
129 const missing = path.join(directory, "missing.jpg");
130 // Named like an image, but a symlink or renamed file must not carry other data.
131 const secret = path.join(directory, "secret.png");
132 yield* fileSystem.writeFileString(secret, "API_KEY=abc123");
133 const report = path.join(directory, "report.svg");
134 yield* fileSystem.writeFileString(report, "<!doctype html><body><svg></svg>API_KEY=abc123");
135 // An <svg> inside a quoted entity, and a prolog shaped to stall a backtracking matcher.
136 const config = path.join(directory, "config.svg");
137 yield* fileSystem.writeFileString(
138 config,
139 '<!DOCTYPE config [<!ENTITY a "a"><!ENTITY b "]><svg/>">]><config>API_KEY=abc123</config>',
140 );
141 const stalling = path.join(directory, "stalling.svg");
142 yield* fileSystem.writeFileString(stalling, `${"<?p?>".repeat(40)}<config><svg/></config>`);
143 const unclosed = path.join(directory, "unclosed.svg");
144 yield* fileSystem.writeFileString(unclosed, '<!DOCTYPE svg [<!ENTITY a "x><svg/>');
145 // Roots named SVG or svgé are other elements.
146 const upper = path.join(directory, "upper.svg");
147 yield* fileSystem.writeFileString(upper, "<SVG/>API_KEY=abc123");
148 const longer = path.join(directory, "longer.svg");
149 yield* fileSystem.writeFileString(longer, "<svg\u00e9/>API_KEY=abc123");
150
151 const error = yield* htmlRender
152 .prepare(
153 `<img src="${missing}"><img src='${folder}'><img src="C:\\nope\\shot.webp"><img src="${secret}"><img src="${report}"><img src="${config}"><img src="${stalling}"><img src="${unclosed}"><img src="${upper}"><img src="${longer}">`,
154 )
155 .pipe(Effect.flip);
156
157 expect(error).toBeInstanceOf(HtmlRender.HtmlRenderImagesNotFoundError);
158 expect(error._tag === "HtmlRenderImagesNotFoundError" && error.paths).toEqual([
159 missing,
160 folder,
161 "C:\\nope\\shot.webp",
162 secret,
163 report,
164 config,
165 stalling,
166 unclosed,
167 upper,
168 longer,
169 ]);
170 }).pipe(Effect.provide(layerTest)),
171 );
172
173 it.effect("publishes the prepared page as an html thread attachment", () =>
174 Effect.gen(function* () {
175 const fileSystem = yield* FileSystem.FileSystem;
176 const config = yield* ServerConfig.ServerConfig;
177 const htmlRender = yield* HtmlRender.HtmlRender;
178
179 const reference = yield* htmlRender.publish({
180 threadId: ThreadId.make("thread-html-render"),
181 html: "<p>Quarterly revenue</p>",
182 title: " Revenue ",
183 height: 9_000,
184 });
185
186 // Without an installed preview browser the page publishes unmeasured.
187 expect(reference).toEqual({
188 attachmentId: expect.any(String),
189 title: "Revenue",
190 height: 2_000,
191 });
192 const stored = resolveAttachmentPathById({
193 attachmentsDir: config.attachmentsDir,
194 attachmentId: reference.attachmentId,
195 });
196 expect(stored?.endsWith(".html")).toBe(true);
197 const html = yield* fileSystem.readFileString(stored ?? "");
198 expect(html).toContain('<style id="t3-theme">');
199 expect(html).toContain("<p>Quarterly revenue</p>");
200 }).pipe(Effect.provide(layerTest)),
201 );
202
203 it.effect("removes the page when publishing is interrupted", () =>
204 Effect.gen(function* () {
205 const measuring = yield* Deferred.make<void>();
206 yield* Effect.gen(function* () {
207 const fileSystem = yield* FileSystem.FileSystem;
208 const config = yield* ServerConfig.ServerConfig;
209 const htmlRender = yield* HtmlRender.HtmlRender;
210 const storedPages = fileSystem
211 .readDirectory(config.attachmentsDir, { recursive: true })
212 .pipe(Effect.map((names) => names.filter((name) => name.endsWith(".html"))));
213
214 const publishing = yield* htmlRender
215 .publish({
216 threadId: ThreadId.make("thread-html-cancel"),
217 html: "<p>x</p>",
218 title: "X",
219 height: 200,
220 })
221 .pipe(Effect.forkChild);
222 yield* Deferred.await(measuring);
223 expect(yield* storedPages).toHaveLength(1);
224 yield* Fiber.interrupt(publishing);
225 expect(yield* storedPages).toEqual([]);
226 }).pipe(
227 Effect.provide(
228 layerHtmlRender(
229 undefined,
230 Deferred.succeed(measuring, undefined).pipe(Effect.andThen(Effect.never)),
231 ),
232 ),
233 );
234 }),
235 );
236
237 it.live(
238 "screenshots the page in headless Chrome with the requested theme and every console level",
239 (ctx) =>
240 Effect.gen(function* () {
241 const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV];
242 if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`);
243 yield* Effect.gen(function* () {
244 const htmlRender = yield* HtmlRender.HtmlRender;
245 const preview = yield* htmlRender.preview({
246 html: [
247 '<!doctype html><html><head></head><body><div id="page" tabindex="0" style="height:300px;background:var(--accent)"></div>',
248 '<img src="/nonexistent/t3-missing.png" hidden>',
249 "<script>",
250 "const root = getComputedStyle(document.documentElement);",
251 'const page = document.getElementById("page"); page.focus();',
252 'const focus = getComputedStyle(page); console.log("focus", focus.outlineWidth, focus.outlineOffset, page.getBoundingClientRect().width, document.documentElement.scrollWidth);',
253 'console.log("ready", 3); console.info(root.getPropertyValue("--font-sans"));',
254 'console.warn(root.getPropertyValue("--background")); console.error("boom");',
255 "</script>",
256 '<script>throw new Error("broken chart");</script>',
257 "</body></html>",
258 ].join(""),
259 width: 400,
260 appearance: "dark",
261 });
262
263 const png = Buffer.from(preview.png, "base64");
264 expect(png.readUInt32BE(0)).toBe(0x89504e47);
265 expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([400, 300]);
266 expect(preview).toMatchObject({
267 width: 400,
268 contentHeight: 300,
269 capturedHeight: 300,
270 missingImages: ["/nonexistent/t3-missing.png"],
271 });
272 // Headless Chrome prefers light, so a dark background proves the theme fragment applied.
273 expect(preview.consoleMessages).toEqual(
274 expect.arrayContaining([
275 { level: "log", text: "ready 3" },
276 { level: "log", text: "focus 2px -2px 400 400" },
277 { level: "info", text: HTML_RENDER_MEASURE_FONTS.sans },
278 {
279 level: "warning",
280 text: htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark").variables["--background"],
281 },
282 { level: "error", text: "boom" },
283 {
284 level: "error",
285 text: expect.stringMatching(/^Error: broken chart\n\s+at page\.html:1:\d+$/),
286 },
287 ]),
288 );
289 }).pipe(Effect.provide(layerHtmlRender(executable)));
290 }),
291 30_000,
292 );
293
294 it.live(
295 "keeps every local file but the page itself out of the browser",
296 (ctx) =>
297 Effect.gen(function* () {
298 const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV];
299 if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`);
300 yield* Effect.gen(function* () {
301 const fileSystem = yield* FileSystem.FileSystem;
302 const path = yield* Path.Path;
303 const htmlRender = yield* HtmlRender.HtmlRender;
304 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-files-" });
305 const secret = path.join(directory, "secret.js");
306 yield* fileSystem.writeFileString(secret, 'window.secret = "abc123";');
307 const secretUrl = NodeURL.pathToFileURL(secret).href;
308
309 const preview = yield* htmlRender.preview({
310 html: [
311 `<script src="${secretUrl}"></script>`,
312 `<iframe src="${secretUrl}" onload="console.log('frame loaded')"></iframe>`,
313 '<script>addEventListener("load", () => console.log("secret:", window.secret ?? "none"));</script>',
314 ].join(""),
315 });
316
317 const texts = preview.consoleMessages.map((message) => message.text);
318 expect(texts).toContain("secret: none");
319 expect(texts.join(" ")).not.toContain("abc123");
320 }).pipe(Effect.scoped, Effect.provide(layerHtmlRender(executable)));
321 }),
322 30_000,
323 );
324
325 it.live(
326 "keeps the page off this machine's local network",
327 (ctx) =>
328 Effect.gen(function* () {
329 const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV];
330 if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`);
331 // The page's connections go through a proxy that only reaches public
332 // addresses, and WebRTC is gone. Each line below is a way out that
333 // Chrome's own Local Network Access does not stop on its own.
334 const requests: Array<string> = [];
335 const server = yield* Effect.acquireRelease(
336 Effect.callback<NodeHttp.Server>((resume) => {
337 const listening = NodeHttp.createServer((request, response) => {
338 requests.push(`${request.url} ${request.headers["sec-purpose"] ?? ""}`);
339 response.end("<p>LOCAL</p>");
340 });
341 listening.listen(0, "127.0.0.1", () => resume(Effect.succeed(listening)));
342 }),
343 (listening) =>
344 Effect.callback<void>((resume) => {
345 listening.close(() => resume(Effect.void));
346 }),
347 );
348 const datagrams: Array<string> = [];
349 const udp = yield* Effect.acquireRelease(
350 Effect.callback<NodeDgram.Socket>((resume) => {
351 const socket = NodeDgram.createSocket("udp4");
352 socket.on("message", (message) => datagrams.push(message.toString("hex")));
353 socket.bind(0, "127.0.0.1", () => resume(Effect.succeed(socket)));
354 }),
355 (socket) =>
356 Effect.callback<void>((resume) => {
357 socket.close(() => resume(Effect.void));
358 }),
359 );
360 const address = server.address();
361 const origin = `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}`;
362 const stun = `stun:127.0.0.1:${udp.address().port}`;
363 yield* Effect.gen(function* () {
364 const htmlRender = yield* HtmlRender.HtmlRender;
365 yield* htmlRender.preview({
366 html: [
367 `<img src="${origin}/x.png"><iframe src="${origin}/"></iframe>`,
368 `<script type="speculationrules">{"prefetch":[{"source":"list","urls":["${origin}/prefetch"]}]}</script>`,
369 `<script>fetch("${origin}/").catch(() => {}); new WebSocket("ws${origin.slice(4)}/socket");`,
370 `try { const peer = new RTCPeerConnection({ iceServers: [{ urls: "${stun}" }] });`,
371 `peer.createDataChannel("x"); peer.createOffer().then((offer) => peer.setLocalDescription(offer));`,
372 `} catch {}`,
373 `window.open("${origin}/popup"); location.href = "${origin}/navigate";</script>`,
374 ].join(""),
375 });
376 }).pipe(Effect.provide(layerHtmlRender(executable)));
377 expect(requests).toEqual([]);
378 expect(datagrams).toEqual([]);
379 }).pipe(Effect.scoped),
380 30_000,
381 );
382
383 it.live(
384 "measures a published page at every client width with a fresh load each",
385 (ctx) =>
386 Effect.gen(function* () {
387 const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV];
388 if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`);
389 yield* Effect.gen(function* () {
390 const htmlRender = yield* HtmlRender.HtmlRender;
391 // Like a D3 chart, the page sizes itself from the width once, at load.
392 const reference = yield* htmlRender.publish({
393 threadId: ThreadId.make("thread-html-measure"),
394 html: '<div id="chart"></div><script>document.getElementById("chart").style.height = innerWidth / 2 + "px";</script>',
395 title: "Chart",
396 height: 600,
397 });
398
399 expect(reference.heights).toEqual(
400 HTML_RENDER_MEASURE_WIDTHS.map((width) => [width, Math.ceil(width / 2)]),
401 );
402 }).pipe(Effect.provide(layerHtmlRender(executable)));
403 }),
404 30_000,
405 );
406});