1// @effect-diagnostics nodeBuiltinImport:off - plain local servers stand in for LAN services.
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, expect, it } from "@effect/vitest";
4import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
5import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
6import * as HostProcess from "@t3tools/shared/HostProcess";
7import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Base64 from "effect/encoding/Base64";
12import * as Fiber from "effect/Fiber";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";
17import * as NodeDgram from "node:dgram";
18import * as NodeHttp from "node:http";
19import * as NodeURL from "node:url";
20
21import { resolveAttachmentPathById } from "../attachmentStore.ts";
22import * as ServerConfig from "../config.ts";
23import * as HtmlRender from "./HtmlRender.ts";
24import { HtmlRenderBrowserError } from "./headlessChrome.ts";
25import * as PreviewBrowser from "../preview/PreviewBrowser.ts";
26import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts";
27
28const TEST_BROWSER_ENV = "T3CODE_TEST_HEADLESS_SHELL";
29
30const layerHtmlRender = (
31 executable?: string,
32 installed: Effect.Effect<Option.Option<string>> = Effect.succeed(
33 Option.fromUndefinedOr(executable),
34 ),
35) =>
36 HtmlRender.layer.pipe(
37 Layer.provide(
38 Layer.succeed(
39 PreviewBrowser.PreviewBrowser,
40 PreviewBrowser.PreviewBrowser.of({
41 executable:
42 executable === undefined
43 ? Effect.die("This test has no preview browser.")
44 : Effect.succeed(executable),
45 installed,
46 }),
47 ),
48 ),
49 Layer.provideMerge(McpProviderSessions.layer),
50 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-html-render-" })),
51 Layer.provideMerge(NodeServices.layer),
52 );
53const layerTest = layerHtmlRender();
54
55const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
56
57describe("HtmlRender", () => {
58 it.effect("inlines local images by absolute path and leaves URLs and relative paths alone", () =>
59 Effect.gen(function* () {
60 const fileSystem = yield* FileSystem.FileSystem;
61 const path = yield* Path.Path;
62 const htmlRender = yield* HtmlRender.HtmlRender;
63 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-images-" });
64 const png = path.join(directory, "shot.png");
65 const svg = path.join(directory, "logo.svg");
66 yield* fileSystem.writeFile(png, PNG_BYTES);
67 yield* fileSystem.writeFileString(svg, "<svg/>");
68 const kept = [
69 "https://example.com/a.png",
70 "//cdn.example.com/b.png",
71 "./c.png",
72 "data:image/png;base64,AAAA",
73 ];
74
75 const prepared = yield* htmlRender.prepare(
76 [
77 "<!doctype html><html><head><title>Shots</title></head><body>",
78 `<img src="${png}"><div style="background:url(${svg})"></div>`,
79 `<script>const shots = ['${png}', \`${svg}\`];</script>`,
80 ...kept.map((src) => `<img src="${src}">`),
81 "</body></html>",
82 ].join(""),
83 );
84
85 const pngUri = `data:image/png;base64,${Base64.encode(PNG_BYTES)}`;
86 const svgUri = `data:image/svg+xml;base64,${Base64.encode("<svg/>")}`;
87 expect(prepared).toContain(`<img src="${pngUri}">`);
88 expect(prepared).toContain(`url(${svgUri})`);
89 expect(prepared).toContain(`['${pngUri}', \`${svgUri}\`]`);
90 expect(prepared).not.toContain(directory);
91 for (const src of kept) expect(prepared).toContain(`<img src="${src}">`);
92 expect(prepared.indexOf("<head>")).toBeLessThan(prepared.indexOf('<style id="t3-theme">'));
93 expect(prepared.indexOf('<style id="t3-theme">')).toBeLessThan(prepared.indexOf("<title>"));
94 }).pipe(Effect.provide(layerTest)),
95 );
96
97 it.effect("rejects viewport height units, which make the fitted frame grow without end", () =>
98 Effect.gen(function* () {
99 const htmlRender = yield* HtmlRender.HtmlRender;
100 for (const page of [
101 "<style>main{min-height:100vh}</style>",
102 "<style media=screen>main{height: calc(100dvh - 2rem)}",
103 "<div style='top:.5svh'></div>",
104 "<div style=height:100vh></div>",
105 ]) {
106 const error = yield* htmlRender.prepare(page).pipe(Effect.flip);
107 expect(error).toBeInstanceOf(HtmlRender.HtmlRenderViewportHeightError);
108 }
109 yield* htmlRender.prepare(
110 '<style>main{width:100vw;height:320px}</style><img src="data:image/png;base64,AB1vh+2vh/3vhC">',
111 );
112 yield* htmlRender.prepare(
113 "<p>Avoid 100vh here.</p><!-- <div style=\"height:100vh\"></div> --><script>const unit = '100vh';</script>",
114 );
115 yield* htmlRender.prepare(`<style>${"1".repeat(200_000)}vx</style>`);
116 }).pipe(Effect.provide(layerTest)),
117 );
118
119 it.effect("inlines an SVG behind processing instructions and a doctype subset", () =>
120 Effect.gen(function* () {
121 const fileSystem = yield* FileSystem.FileSystem;
122 const path = yield* Path.Path;
123 const htmlRender = yield* HtmlRender.HtmlRender;
124 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-images-" });
125 const svg = path.join(directory, "styled.svg");
126 const source = [
127 '<?xml version="1.0"?>',
128 '<?xml-stylesheet href="theme.css"?>',
129 '<!DOCTYPE svg [ <!ENTITY fill "red"> ]>',
130 '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"/>',
131 ].join("\n");
132 yield* fileSystem.writeFileString(svg, source);
133
134 const prepared = yield* htmlRender.prepare(`<img src="${svg}">`);
135
136 expect(prepared).toContain(`data:image/svg+xml;base64,${Base64.encode(source)}`);
137 }).pipe(Effect.provide(layerTest)),
138 );
139
140 it.effect("lists every local image it cannot read", () =>
141 Effect.gen(function* () {
142 const fileSystem = yield* FileSystem.FileSystem;
143 const path = yield* Path.Path;
144 const htmlRender = yield* HtmlRender.HtmlRender;
145 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-images-" });
146 const folder = path.join(directory, "folder.png");
147 yield* fileSystem.makeDirectory(folder);
148 const missing = path.join(directory, "missing.jpg");
149 const secret = path.join(directory, "secret.png");
150 yield* fileSystem.writeFileString(secret, "API_KEY=abc123");
151 const report = path.join(directory, "report.svg");
152 yield* fileSystem.writeFileString(report, "<!doctype html><body><svg></svg>API_KEY=abc123");
153 const config = path.join(directory, "config.svg");
154 yield* fileSystem.writeFileString(
155 config,
156 '<!DOCTYPE config [<!ENTITY a "a"><!ENTITY b "]><svg/>">]><config>API_KEY=abc123</config>',
157 );
158 const stalling = path.join(directory, "stalling.svg");
159 yield* fileSystem.writeFileString(stalling, `${"<?p?>".repeat(40)}<config><svg/></config>`);
160 const unclosed = path.join(directory, "unclosed.svg");
161 yield* fileSystem.writeFileString(unclosed, '<!DOCTYPE svg [<!ENTITY a "x><svg/>');
162 const upper = path.join(directory, "upper.svg");
163 yield* fileSystem.writeFileString(upper, "<SVG/>API_KEY=abc123");
164 const longer = path.join(directory, "longer.svg");
165 yield* fileSystem.writeFileString(longer, "<svg\u00e9/>API_KEY=abc123");
166
167 const error = yield* htmlRender
168 .prepare(
169 `<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}">`,
170 )
171 .pipe(Effect.flip);
172
173 expect(error).toBeInstanceOf(HtmlRender.HtmlRenderImagesNotFoundError);
174 expect(error._tag === "HtmlRenderImagesNotFoundError" && error.paths).toEqual([
175 missing,
176 folder,
177 "C:\\nope\\shot.webp",
178 secret,
179 report,
180 config,
181 stalling,
182 unclosed,
183 upper,
184 longer,
185 ]);
186 }).pipe(Effect.provide(layerTest)),
187 );
188
189 it.effect("publishes the prepared page as an html thread attachment", () =>
190 Effect.gen(function* () {
191 const fileSystem = yield* FileSystem.FileSystem;
192 const config = yield* ServerConfig.ServerConfig;
193 const htmlRender = yield* HtmlRender.HtmlRender;
194 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
195 const threadId = ThreadId.make("thread-html-render");
196 const session = {
197 environmentId: EnvironmentId.make("html-render-environment"),
198 threadId,
199 providerSessionId: "first-credential",
200 providerInstanceId: ProviderInstanceId.make("claude"),
201 endpoint: "http://localhost:8311/mcp",
202 authorizationHeader: "Bearer first-token",
203 browserToolsAvailable: false,
204 };
205 yield* mcpSessions.set(session);
206
207 const reference = yield* htmlRender.publish({
208 threadId,
209 html: `<p>Quarterly revenue</p>${" ".repeat(8_000)}`,
210 title: " Revenue ",
211 height: 9_000,
212 });
213
214 expect(reference).toEqual({
215 attachmentId: expect.any(String),
216 title: "Revenue",
217 height: 2_000,
218 });
219 const stored = resolveAttachmentPathById({
220 attachmentsDir: config.attachmentsDir,
221 attachmentId: reference.attachmentId,
222 });
223 expect(stored?.endsWith(".html")).toBe(true);
224 const html = yield* fileSystem.readFileString(stored ?? "");
225 expect(html).toContain('<style id="t3-theme">');
226 expect(html).toContain("<p>Quarterly revenue</p>");
227 // Antigravity reports the call without its result, so the thread's
228 // provider session keeps the page for one call with these arguments.
229 const page = {
230 html: `<p>Quarterly revenue</p>${" ".repeat(8_000)}`,
231 title: " Revenue ",
232 height: 9_000,
233 };
234 expect(
235 yield* mcpSessions.takeHtmlRender(threadId, {
236 ...page,
237 html: page.html.replace("revenue", "expense"),
238 }),
239 ).toBe(undefined);
240 expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toEqual(reference);
241 expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toBe(undefined);
242 const reused = yield* htmlRender.publish({ threadId, ...page });
243 yield* mcpSessions.set({ ...session, browserToolsAvailable: true });
244 expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toEqual(reused);
245
246 yield* htmlRender.publish({ threadId, ...page });
247 yield* mcpSessions.set({
248 ...session,
249 providerSessionId: "replacement-credential",
250 providerInstanceId: ProviderInstanceId.make("antigravity"),
251 authorizationHeader: "Bearer replacement-token",
252 });
253 const replacement = yield* htmlRender.publish({ threadId, ...page });
254 expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toEqual(replacement);
255 expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toBe(undefined);
256 // Clearing the thread's MCP session drops the pages it kept.
257 yield* htmlRender.publish({ threadId, ...page });
258 yield* mcpSessions.clear(threadId);
259 expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toBe(undefined);
260 }).pipe(Effect.provide(layerTest)),
261 );
262
263 it.effect("publishes a page from an .html file as a snapshot that path calls recover", () =>
264 Effect.gen(function* () {
265 const fileSystem = yield* FileSystem.FileSystem;
266 const path = yield* Path.Path;
267 const config = yield* ServerConfig.ServerConfig;
268 const htmlRender = yield* HtmlRender.HtmlRender;
269 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
270 const threadId = ThreadId.make("thread-html-path");
271 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-page-" });
272 const file = path.join(directory, "report.html");
273 yield* fileSystem.writeFileString(file, "<!doctype html><p>First draft</p>");
274 const page = { path: file, title: "Report", height: 300 };
275
276 const first = yield* htmlRender.publish({ threadId, ...page });
277 yield* fileSystem.writeFileString(file, "<!doctype html><p>Second draft</p>");
278 const second = yield* htmlRender.publish({ threadId, ...page });
279 const inline = { html: "<!doctype html><p>First draft</p>", title: "Report", height: 300 };
280 const inlineReference = yield* htmlRender.publish({ threadId, ...inline });
281
282 const stored = yield* fileSystem.readFileString(
283 resolveAttachmentPathById({
284 attachmentsDir: config.attachmentsDir,
285 attachmentId: first.attachmentId,
286 }) ?? "",
287 );
288 expect(stored).toContain("<p>First draft</p>");
289 expect(yield* mcpSessions.takeHtmlRender(threadId, { ...page, path: `${file}x` })).toBe(
290 undefined,
291 );
292 for (const html of [inline.html, false]) {
293 expect(yield* mcpSessions.takeHtmlRender(threadId, { ...page, html })).toBe(undefined);
294 }
295 expect(yield* mcpSessions.takeHtmlRender(threadId, { ...inline, path: null })).toEqual(
296 inlineReference,
297 );
298 expect(yield* mcpSessions.takeHtmlRender(threadId, { ...page, html: null })).toEqual(first);
299 expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toEqual(second);
300 expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toBe(undefined);
301 }).pipe(Effect.provide(layerTest)),
302 );
303
304 it.effect("refuses anything but a regular, singly linked .html file of at most 25 MiB", () =>
305 Effect.gen(function* () {
306 const fileSystem = yield* FileSystem.FileSystem;
307 const path = yield* Path.Path;
308 const config = yield* ServerConfig.ServerConfig;
309 const htmlRender = yield* HtmlRender.HtmlRender;
310 const threadId = ThreadId.make("thread-html-path-guard");
311 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-page-" });
312 const secret = path.join(directory, "id_rsa");
313 yield* fileSystem.writeFileString(secret, "PRIVATE KEY");
314 const page = path.join(directory, "page.html");
315 yield* fileSystem.writeFileString(page, "<!doctype html><p>linked</p>");
316 const hardLinked = path.join(directory, "linked.html");
317 yield* fileSystem.link(page, hardLinked);
318 // `mv .env renamed.html` keeps one link and an .html name; only its bytes give it away.
319 const renamed = path.join(directory, "renamed.html");
320 yield* fileSystem.writeFileString(renamed, "API_KEY=abc123\n");
321 const folder = path.join(directory, "folder.html");
322 yield* fileSystem.makeDirectory(folder);
323 const internal = path.join(config.secretsDir, "page.html");
324 yield* fileSystem.makeDirectory(config.secretsDir, { recursive: true });
325 yield* fileSystem.writeFileString(internal, "<!doctype html><p>internal</p>");
326
327 for (const file of [
328 "page.html",
329 "//attacker/share/page.html",
330 path.join(directory, "missing.html"),
331 secret,
332 hardLinked,
333 renamed,
334 folder,
335 internal,
336 ]) {
337 const error = yield* htmlRender
338 .publish({ threadId, path: file, title: "X", height: 200 })
339 .pipe(Effect.flip);
340 expect(error, file).toBeInstanceOf(HtmlRender.HtmlRenderFileError);
341 }
342 const huge = path.join(directory, "huge.html");
343 yield* fileSystem.writeFileString(huge, "");
344 yield* fileSystem.truncate(huge, 25 * 1024 * 1024 + 1);
345 const tooLarge = yield* htmlRender
346 .publish({ threadId, path: huge, title: "X", height: 200 })
347 .pipe(Effect.flip);
348 expect(tooLarge).toBeInstanceOf(HtmlRender.HtmlRenderFileTooLargeError);
349 }).pipe(Effect.provide(layerTest)),
350 );
351
352 it.effect.skipIf(!symlinksSupported)("refuses an .html symlink to another file", () =>
353 Effect.gen(function* () {
354 const fileSystem = yield* FileSystem.FileSystem;
355 const path = yield* Path.Path;
356 const htmlRender = yield* HtmlRender.HtmlRender;
357 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-page-" });
358 const secret = path.join(directory, "id_rsa");
359 yield* fileSystem.writeFileString(secret, "PRIVATE KEY");
360 const symlinked = path.join(directory, "symlinked.html");
361 yield* fileSystem.symlink(secret, symlinked);
362
363 const error = yield* htmlRender
364 .publish({
365 threadId: ThreadId.make("thread-html-path-link"),
366 path: symlinked,
367 title: "X",
368 height: 200,
369 })
370 .pipe(Effect.flip);
371
372 expect(error).toBeInstanceOf(HtmlRender.HtmlRenderFileError);
373 }).pipe(Effect.provide(layerTest)),
374 );
375
376 it.effect("removes the page when publishing is interrupted", () =>
377 Effect.gen(function* () {
378 const threadId = ThreadId.make("thread-html-cancel");
379 const page = { html: "<p>x</p>", title: "X", height: 200 };
380 const measuring = yield* Deferred.make<void>();
381 yield* Effect.gen(function* () {
382 const fileSystem = yield* FileSystem.FileSystem;
383 const config = yield* ServerConfig.ServerConfig;
384 const htmlRender = yield* HtmlRender.HtmlRender;
385 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
386 const storedPages = fileSystem
387 .readDirectory(config.attachmentsDir, { recursive: true })
388 .pipe(Effect.map((names) => names.filter((name) => name.endsWith(".html"))));
389
390 const publishing = yield* htmlRender.publish({ threadId, ...page }).pipe(Effect.forkChild);
391 yield* Deferred.await(measuring);
392 expect(yield* storedPages).toHaveLength(1);
393 yield* Fiber.interrupt(publishing);
394 expect(yield* storedPages).toEqual([]);
395 expect(yield* mcpSessions.takeHtmlRender(threadId, page)).toBe(undefined);
396 }).pipe(
397 Effect.provide(
398 layerHtmlRender(
399 undefined,
400 Deferred.succeed(measuring, undefined).pipe(Effect.andThen(Effect.never)),
401 ),
402 ),
403 );
404 }),
405 );
406
407 // Windows reports ENOENT asynchronously for this path instead of throwing ENOTDIR.
408 describe.skipIf(HostProcess.Platform.defaultValue() === "win32")(
409 "when the browser spawn throws synchronously",
410 () => {
411 it.live("reports a typed preview error", () =>
412 Effect.gen(function* () {
413 const fileSystem = yield* FileSystem.FileSystem;
414 const path = yield* Path.Path;
415 const file = yield* fileSystem.makeTempFileScoped({ prefix: "t3-browser-spawn-" });
416 const outcome = yield* HtmlRender.HtmlRender.use((htmlRender) =>
417 htmlRender.preview({ html: "<p>x</p>" }).pipe(Effect.flip),
418 ).pipe(
419 Effect.provide(layerHtmlRender(path.join(file, "chrome-headless-shell"))),
420 Effect.exit,
421 );
422 assert(Exit.isSuccess(outcome), "Expected a typed preview error.");
423 const error = outcome.value;
424 expect(error).toBeInstanceOf(HtmlRenderBrowserError);
425 expect(error.message).toBe(
426 "Headless Chrome could not render the page: the browser could not be started.",
427 );
428 expect(error).toMatchObject({ cause: { code: "ENOTDIR" } });
429 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
430 );
431 },
432 );
433
434 // The fake browser is a shebang script, which Windows cannot execute.
435 describe.skipIf(HostProcess.Platform.defaultValue() === "win32")(
436 "when the browser cannot be signalled",
437 () => {
438 // A browser T3 cannot signal, as under AppArmor in an LXC container, must still exit.
439 it.live("asks the browser to close when the preview finishes instead of signalling it", () =>
440 Effect.gen(function* () {
441 const fileSystem = yield* FileSystem.FileSystem;
442 const path = yield* Path.Path;
443 const directory = yield* fileSystem.makeTempDirectoryScoped({
444 prefix: "t3-fake-browser-",
445 });
446 const executable = path.join(directory, "chrome-headless-shell");
447 const exitRecord = path.join(directory, "exited");
448 yield* fileSystem.writeFileString(
449 executable,
450 `#!${process.execPath}
451const fs = require("node:fs");
452process.on("SIGTERM", () => {});
453const quit = () => { fs.writeFileSync(${JSON.stringify(exitRecord)}, "asked to close"); process.exit(0); };
454const reply = (message) => fs.writeSync(4, JSON.stringify(message) + "\\0");
455const results = {
456 "Target.createBrowserContext": { browserContextId: "context" },
457 "Target.createTarget": { targetId: "page" },
458 "Target.attachToTarget": { sessionId: "session" },
459 "Runtime.evaluate": { result: { value: 100 } },
460 "Page.captureScreenshot": { data: "iVBORw0KGgo=" },
461};
462let buffered = "";
463fs.createReadStream(null, { fd: 3 }).on("end", quit).on("data", (chunk) => {
464 const messages = (buffered + chunk).split("\\0");
465 buffered = messages.pop();
466 for (const { id, method, sessionId } of messages.map((raw) => JSON.parse(raw))) {
467 if (method === "Browser.close") quit();
468 reply({ id, sessionId, result: results[method] ?? {} });
469 if (method === "Page.navigate") {
470 const value = "x" + "\u{1F600}".repeat(300);
471 const params = { type: "log", args: [{ type: "string", value }] };
472 reply({ sessionId, method: "Runtime.consoleAPICalled", params });
473 reply({ sessionId, method: "Page.loadEventFired", params: {} });
474 }
475 }
476});
477`,
478 { mode: 0o755 },
479 );
480
481 const preview = yield* HtmlRender.HtmlRender.use((htmlRender) =>
482 htmlRender.preview({ html: "<p>x</p>" }),
483 ).pipe(Effect.provide(layerHtmlRender(executable)));
484 const logged = preview.consoleMessages[0]?.text ?? "";
485 expect(logged.isWellFormed()).toBe(true);
486 expect(logged).toHaveLength(500);
487
488 const exitedBy = yield* fileSystem.readFileString(exitRecord).pipe(Effect.option);
489 expect(Option.getOrElse(exitedBy, () => "killed by a signal")).toBe("asked to close");
490 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
491 );
492
493 it.live("names this server's home in the browser setup command", () =>
494 Effect.gen(function* () {
495 const fileSystem = yield* FileSystem.FileSystem;
496 const path = yield* Path.Path;
497 const directory = yield* fileSystem.makeTempDirectoryScoped({
498 prefix: "t3-fake-browser-",
499 });
500 const executable = path.join(directory, "chrome-headless-shell");
501 yield* fileSystem.writeFileString(
502 executable,
503 `#!${process.execPath}\nprocess.stderr.write(${JSON.stringify(PreviewBrowserHost.NO_SANDBOX_SIGNATURE)});\nprocess.exit(1);\n`,
504 { mode: 0o755 },
505 );
506
507 const { error, baseDir } = yield* Effect.gen(function* () {
508 const { baseDir } = yield* ServerConfig.ServerConfig;
509 const htmlRender = yield* HtmlRender.HtmlRender;
510 const error = yield* htmlRender.preview({ html: "<p>x</p>" }).pipe(Effect.flip);
511 return { error, baseDir };
512 }).pipe(
513 Effect.provide(layerHtmlRender(executable)),
514 Effect.provideService(HostProcess.UserId, 1000),
515 );
516 expect(error).toBeInstanceOf(PreviewBrowserHost.PreviewBrowserSandboxError);
517 expect(error.message).toContain(`browser setup --base-dir ${baseDir}`);
518 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
519 );
520 },
521 );
522
523 it.live("matches preview media queries and text colors to the requested appearance", (ctx) =>
524 Effect.gen(function* () {
525 const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV];
526 if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`);
527 yield* Effect.gen(function* () {
528 const htmlRender = yield* HtmlRender.HtmlRender;
529 for (const appearance of [undefined, "dark", "light"] as const) {
530 const preview = yield* htmlRender.preview({
531 appearance,
532 html: [
533 "<style>h1{color:#18181b}@media(prefers-color-scheme:dark){h1{color:#fafafa}}</style>",
534 "<h1>The moon garden opens at dusk</h1>",
535 '<script>console.log(matchMedia("(prefers-color-scheme:dark)").matches,',
536 'getComputedStyle(document.querySelector("h1")).color);</script>',
537 ].join(""),
538 });
539 expect(preview.consoleMessages).toContainEqual({
540 level: "log",
541 text: appearance === "light" ? "false rgb(24, 24, 27)" : "true rgb(250, 250, 250)",
542 });
543 }
544 }).pipe(Effect.provide(layerHtmlRender(executable)));
545 }),
546 );
547
548 it.live("keeps every local file but the page itself out of the browser", (ctx) =>
549 Effect.gen(function* () {
550 const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV];
551 if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`);
552 yield* Effect.gen(function* () {
553 const fileSystem = yield* FileSystem.FileSystem;
554 const path = yield* Path.Path;
555 const htmlRender = yield* HtmlRender.HtmlRender;
556 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-files-" });
557 const secret = path.join(directory, "secret.js");
558 yield* fileSystem.writeFileString(secret, 'window.secret = "abc123";');
559 const secretUrl = NodeURL.pathToFileURL(secret).href;
560
561 const preview = yield* htmlRender.preview({
562 html: [
563 `<script src="${secretUrl}"></script>`,
564 `<iframe src="${secretUrl}" onload="console.log('frame loaded')"></iframe>`,
565 '<script>addEventListener("load", () => console.log("secret:", window.secret ?? "none"));</script>',
566 ].join(""),
567 });
568
569 const texts = preview.consoleMessages.map((message) => message.text);
570 expect(texts).toContain("secret: none");
571 expect(texts.join(" ")).not.toContain("abc123");
572 }).pipe(Effect.scoped, Effect.provide(layerHtmlRender(executable)));
573 }),
574 );
575
576 it.live("keeps the page off this machine's local network", (ctx) =>
577 Effect.gen(function* () {
578 const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV];
579 if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`);
580 const requests: Array<string> = [];
581 const server = yield* Effect.acquireRelease(
582 Effect.callback<NodeHttp.Server>((resume) => {
583 const listening = NodeHttp.createServer((request, response) => {
584 requests.push(`${request.url} ${request.headers["sec-purpose"] ?? ""}`);
585 response.end("<p>LOCAL</p>");
586 });
587 listening.listen(0, "127.0.0.1", () => resume(Effect.succeed(listening)));
588 }),
589 (listening) =>
590 Effect.callback<void>((resume) => {
591 listening.close(() => resume(Effect.void));
592 }),
593 );
594 const datagrams: Array<string> = [];
595 const udp = yield* Effect.acquireRelease(
596 Effect.callback<NodeDgram.Socket>((resume) => {
597 const socket = NodeDgram.createSocket("udp4");
598 socket.on("message", (message) => datagrams.push(message.toString("hex")));
599 socket.bind(0, "127.0.0.1", () => resume(Effect.succeed(socket)));
600 }),
601 (socket) =>
602 Effect.callback<void>((resume) => {
603 socket.close(() => resume(Effect.void));
604 }),
605 );
606 const address = server.address();
607 const origin = `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}`;
608 const stun = `stun:127.0.0.1:${udp.address().port}`;
609 yield* Effect.gen(function* () {
610 const htmlRender = yield* HtmlRender.HtmlRender;
611 yield* htmlRender.preview({
612 html: [
613 `<img src="${origin}/x.png"><iframe src="${origin}/"></iframe>`,
614 `<script type="speculationrules">{"prefetch":[{"source":"list","urls":["${origin}/prefetch"]}]}</script>`,
615 `<script>fetch("${origin}/").catch(() => {}); new WebSocket("ws${origin.slice(4)}/socket");`,
616 `try { const peer = new RTCPeerConnection({ iceServers: [{ urls: "${stun}" }] });`,
617 `peer.createDataChannel("x"); peer.createOffer().then((offer) => peer.setLocalDescription(offer));`,
618 `} catch {}`,
619 `window.open("${origin}/popup"); location.href = "${origin}/navigate";</script>`,
620 ].join(""),
621 });
622 }).pipe(Effect.provide(layerHtmlRender(executable)));
623 expect(requests).toEqual([]);
624 expect(datagrams).toEqual([]);
625 }),
626 );
627});