1// ## pages
2test("a page that this machine asks for gets the visualization, and the hash it can ask for again", async () => {
3 const debug = new incremental.Debugger(new incremental.Graph({ db: null }));
4 debug.update(result({ ok: true, value: null }));
5 let acceptEncoding: string | null = "unread";
6 const respond = debug.respond(async (request) => {
7 acceptEncoding = request.headers.get("accept-encoding");
8 return new Response("<p>hello</p>", { headers: { "content-type": "text/html", "content-length": "12" } });
9 });
10 const ask = (headers: Record<string, string>, init: RequestInit & { remoteAddress?: string } = {}) =>
11 respond(new Request("http://localhost:3000/page", { ...init, headers: { host: "localhost:3000", ...headers } }), {
12 node: progress.nullNode,
13 remoteAddress: init.remoteAddress ?? "127.0.0.1",
14 });
15
16 const page = await ask({ "sec-fetch-dest": "document", "accept-encoding": "gzip" });
17 const html = await page.text();
18 const hash = UNWRAP(/visualization\.ts\?page=([\w-]+)/.exec(html), html)[1];
19 assert.ok(html.startsWith("<p>hello</p><script type=module src=/.clover/lib/incremental/visualization.ts?page="));
20 assert.equal(page.headers.get("content-length"), null);
21 // a page is read whole, which a compressed one can't be
22 assert.equal(acceptEncoding, null);
23
24 const check = await ask({ "clover-page": "" });
25 assert.equal(check.status, 204);
26 assert.equal(check.headers.get("clover-page"), hash);
27
28 // what was posted can't be asked for again, so it never reloads
29 const posted = await ask({ "sec-fetch-dest": "document" }, { method: "POST", body: "a=1" });
30 assert.match(await posted.text(), /visualization\.ts><\/script>$/);
31});
32
33test("a page asked for before a build was served shows that build, until it can reload into the page", async () => {
34 const debug = new incremental.Debugger(new incremental.Graph({ db: null }));
35 const respond = debug.respond(async () => new Response("<p>hello</p>", { headers: { "content-type": "text/html" } }));
36 const ask = () =>
37 respond(
38 new Request("http://localhost:3000/page", { headers: { host: "localhost:3000", "sec-fetch-dest": "document" } }),
39 { node: progress.nullNode, remoteAddress: "127.0.0.1" },
40 ).then((response) => response.text());
41
42 assert.match(await ask(), /^<!doctype html>.*visualization\.ts\?page=&graph><\/script>$/);
43 debug.update(result({ ok: true, value: null }));
44 assert.match(
45 await ask(),
46 /^<p>hello<\/p><script type=module src=\/\.clover\/lib\/incremental\/visualization\.ts\?page=[\w-]+>/,
47 );
48});
49
50test("a request through a proxy, from another machine, or for another name is served as it is", async () => {
51 const debug = new incremental.Debugger(new incremental.Graph({ db: null }));
52 debug.update(result({ ok: true, value: null }));
53 const respond = debug.respond(async () => new Response("<p>hello</p>", { headers: { "content-type": "text/html" } }));
54 const page = (headers: Record<string, string>, remoteAddress: string) =>
55 respond(new Request("http://localhost:3000/", { headers: { "sec-fetch-dest": "document", ...headers } }), {
56 node: progress.nullNode,
57 remoteAddress,
58 }).then((response) => response.text());
59
60 assert.notEqual(await page({ host: "localhost:3000" }, "::1"), "<p>hello</p>");
61 assert.notEqual(await page({ host: "127.0.0.1:3000" }, "::ffff:127.0.0.1"), "<p>hello</p>");
62 assert.equal(await page({ host: "localhost:3000" }, "192.168.1.20"), "<p>hello</p>");
63 assert.equal(await page({ host: "localhost:3000", "x-forwarded-for": "1.2.3.4" }, "127.0.0.1"), "<p>hello</p>");
64 assert.equal(await page({ host: "localhost:3000", forwarded: "for=1.2.3.4" }, "127.0.0.1"), "<p>hello</p>");
65 // a name that points here by dns can be any page's
66 assert.equal(await page({ host: "rebound.example:3000" }, "127.0.0.1"), "<p>hello</p>");
67});
68
69test("the library is served as javascript, and only its typescript", async () => {
70 const debug = new incremental.Debugger(new incremental.Graph({ db: null }));
71 const respond = debug.respond(async () => new Response("from the site", { status: 404 }));
72 const get = (pathname: string) =>
73 respond(new Request(`http://localhost:3000${pathname}`, { headers: { host: "localhost:3000" } }), {
74 node: progress.nullNode,
75 remoteAddress: "127.0.0.1",
76 });
77
78 const code = await get("/.clover/lib/incremental/visualization.ts");
79 assert.equal(code.headers.get("content-type"), "text/javascript;charset=utf-8");
80 const text = await code.text();
81 assert.match(text, /^import \* as progress from "\.\.\/progress\.ts";$/m);
82 assert.doesNotMatch(text, /import type/);
83 // safari has no `using`, nor what it disposes with
84 assert.match(text, /^import "\.\.\/safari\.js";$/m);
85 assert.match(await (await get("/.clover/lib/safari.js")).text(), /DisposableStack/);
86 assert.doesNotMatch(await (await get("/.clover/lib/progress.ts")).text(), /^\s*using /m);
87 assert.doesNotMatch(await (await get("/.clover/lib/log.ts")).text(), /using/);
88 for (const pathname of ["/.clover/lib/package.json", "/.clover/lib/missing.ts", "/.clover/lib/..%2Fpackage.json"]) {
89 assert.equal((await get(pathname)).status, 404, pathname);
90 }
91 assert.equal(await (await get("/.clover/lib/%2e%2e/run.js")).text(), "from the site");
92});
93
94// ## the socket
95test("a page hears of every build, its progress, and its errors", async () => {
96 const debug = new incremental.Debugger(new incremental.Graph({ db: null }));
97 await using server = await http.serve({
98 respond: debug.respond(async () => new Response("")),
99 upgrade: debug.upgrade,
100 port: 0,
101 });
102 // as a build's errors say where they came from
103 const error = Object.defineProperties(new Error("the page did not render"), {
104 steps: { value: ["render"] },
105 fatal: { value: true },
106 cached: { value: false },
107 });
108
109 debug.update(result({ ok: true, value: null }));
110 const page = await connect(server.url, { origin: server.url.slice(0, -1) });
111 const [type, { at, ...status }] = await page.next("status");
112 assert.equal(type, "status");
113 assert.ok(Math.abs(at - Date.now()) < 5000);
114 assert.deepEqual(status, {
115 state: "ok",
116 earlier: false,
117 changed: ["src/page.marko", "src/"],
118 stats: { duration: 12, ran: 1, reused: 2, written: 3, cacheBytes: 4, outputBytes: 5 },
119 statusLine: "Watching 1 file",
120 errors: [],
121 tokens: {},
122 });
123
124 {
125 using node = debug.progress.start("build");
126 assert.deepEqual(await page.next(), ["building"]);
127 const [type, event] = await page.next();
128 assert.equal(type, "progress");
129 assert.ok(JSON.stringify(event).includes("\"build\""));
130 node.end();
131 }
132
133 // errors that a build is still served with are warnings
134 debug.update(result({ ok: false, error: new incremental.BuildError([error], null), value: null }));
135 const warnings = await page.next("status");
136 assert.equal(warnings[1].state, "warnings");
137 const [diagnostic] = warnings[1].errors;
138 assert.deepEqual([diagnostic?.label, diagnostic?.message, diagnostic?.notes.at(-1)?.text], [
139 "error",
140 "the page did not render",
141 "thrown in render",
142 ]);
143 assert.match(UNWRAP(diagnostic?.location?.file), /debugger\.test\.ts$/);
144 // the code is colored before a page gets it
145 const { file, line, first, code } = UNWRAP(diagnostic?.location);
146 const text = UNWRAP(code[UNWRAP(line) - first]);
147 assert.equal(warnings[1].tokens[`${file}:${line}`]?.[text.indexOf(`"the page`)], "s");
148 debug.update(result({ ok: false, error: new incremental.BuildError([error], undefined) }));
149 assert.equal((await page.next("status"))[1].state, "failed");
150 // what a build made can fail to load, which leaves the last build served
151 debug.update(result({ ok: true, value: null }), new Error("the backend did not load"));
152 const [, { state, earlier, errors: [unloaded] }] = await page.next("status");
153 assert.deepEqual([state, earlier, unloaded?.message, unloaded?.notes.at(-1)?.text], [
154 "failed",
155 true,
156 "the backend did not load",
157 "thrown while the handler loaded",
158 ]);
159
160 // a page that leaves is answered, and is not written to again
161 assert.equal(await page.close(), true);
162 debug.update(result({ ok: true, value: null }));
163});
164
165test("a page hears what happens to steps, and reads the whole graph", async () => {
166 const graph = new incremental.Graph({ db: null });
167 const debug = new incremental.Debugger(graph);
168 await using server = await http.serve({
169 respond: debug.respond(async () => new Response("")),
170 upgrade: debug.upgrade,
171 port: 0,
172 });
173 const page = await connect(server.url, {});
174 function greet() {
175 return "hello";
176 }
177 await graph.incrementalBuild({
178 progress: debug.progress,
179 buildAndWriteOutput: (ref) => ref.step(greet).read(),
180 });
181
182 const heard: incremental.StepEvent[] = [];
183 while (!heard.some((event) => event.state === "ran" && event.label.startsWith("greet"))) {
184 heard.push(...(await page.next("steps"))[1]);
185 }
186 const greeting = heard.filter((event) => event.label.startsWith("greet")).map((event) => event.state);
187 assert.deepEqual(greeting, ["checking", "running", "ran"]);
188
189 const snapshot = await debug.respond(async () => new Response(""))(
190 new Request(new URL("/.clover/graph", server.url), { headers: { host: new URL(server.url).host } }),
191 { node: progress.nullNode, remoteAddress: "127.0.0.1" },
192 ).then((response) => response.json() as Promise<incremental.GraphSnapshot>);
193 const step = UNWRAP(snapshot.steps.find((step) => step.label.startsWith("greet")));
194 // a step depends on the code that started it
195 assert.ok(step.steps.some((key) => key.startsWith("closure:") && key.endsWith("/debugger.test.ts")));
196 assert.ok(step.bytes > 0);
197 assert.ok(snapshot.inputs.some((input) => input.url.endsWith("/debugger.test.ts")));
198});
199
200test("only this server's pages can open the socket", async () => {
201 const debug = new incremental.Debugger(new incremental.Graph({ db: null }));
202 await using server = await http.serve({
203 respond: debug.respond(async () => new Response("")),
204 upgrade: debug.upgrade,
205 port: 0,
206 });
207 await assert.rejects(connect(server.url, { origin: "http://elsewhere.example" }));
208 await assert.rejects(connect(server.url, { "x-forwarded-for": "1.2.3.4" }));
209});
210
211test("a page asked for with an upgrade to another protocol is served as a page", async () => {
212 const debug = new incremental.Debugger(new incremental.Graph({ db: null }));
213 await using server = await http.serve({
214 respond: debug.respond(async () => new Response("page")),
215 upgrade: debug.upgrade,
216 shouldUpgrade: debug.shouldUpgrade,
217 port: 0,
218 });
219 // how `curl --http2` asks for a page
220 const client = net.connect(server.port, "127.0.0.1");
221 client.write(
222 "GET / HTTP/1.1\r\nhost: localhost\r\nconnection: Upgrade, HTTP2-Settings, close\r\nupgrade: h2c\r\n"
223 + "http2-settings: AAMAAABkAAQCAAAAAAIAAAAA\r\n\r\n",
224 );
225 const response = await consumers.text(client);
226 assert.match(response, /^HTTP\/1\.1 200 /);
227 assert.match(response, /page/);
228 assert.ok(await (await connect(server.url, {})).close());
229});
230
231// ## serving
232test("a server listens before the first build, and a request waits for what serves it", async () => {
233 await using server = await serve((ref) =>
234 ref.step(function greet() {
235 return "hello";
236 }).read()
237 );
238 const page = fetch(server.url);
239 const builds = server.watch();
240 const first = await builds.next();
241 assert.ok(!first.done && first.value.ok);
242 server.setHandler(() => new Response(first.value.value));
243 assert.equal(await (await page).text(), "hello");
244 await builds.return();
245});
246
247test("a handler given as a promise serves once it loads", async () => {
248 await using server = await serve(async () => "hello");
249 const page = fetch(server.url);
250 const builds = server.watch();
251 const first = await builds.next();
252 assert.ok(!first.done && first.value.ok);
253 server.setHandler(Promise.resolve(() => new Response(first.value.value)));
254 assert.equal(await (await page).text(), "hello");
255 await builds.return();
256});
257
258test("a build that left nothing to serve answers requests with why", async () => {
259 await using server = await serve((ref) =>
260 ref.step(function fail(): string {
261 throw new Error("the page did not render");
262 }).read()
263 );
264 const page = fetch(server.url);
265 const builds = server.watch();
266 const first = await builds.next();
267 assert.ok(!first.done && !first.value.ok);
268 assert.equal((await page).status, 503);
269 await builds.return();
270});
271
272test("a request waits for the handler of a build that returned nothing", async () => {
273 // the build takes long enough for the request to be waiting by its end
274 await using server = await serve(() => new Promise((resolve) => setTimeout(resolve, 100)));
275 const page = fetch(server.url);
276 const builds = server.watch();
277 await builds.next();
278 server.setHandler(() => new Response("hello"));
279 assert.equal(await (await page).text(), "hello");
280 await builds.return();
281});
282
283test("a server that reads its files only once serves pages without a debugger", async () => {
284 await using server = await serve(async () => {});
285 const builds = server.watch();
286 await builds.next();
287 server.setHandler(() => new Response("<p>hello</p>", { headers: { "content-type": "text/html" } }));
288 const page = await fetch(server.url, { headers: { "sec-fetch-dest": "document" } });
289 assert.equal(await page.text(), "<p>hello</p>");
290 await builds.return();
291});
292
293/** a quiet server on a random port, which reads its files once */
294function serve<T>(buildAndWriteOutput: (ref: incremental.Ref) => Promise<T>) {
295 return incremental.serve({
296 graph: new incremental.Graph({ db: null }),
297 buildAndWriteOutput,
298 watchFilesystem: false,
299 port: 0,
300 log: log.headlessScope(() => {}),
301 progress: new progress.Root(),
302 });
303}
304
305/** what a build did, as a watch yields it */
306function result(outcome: { ok: true; value: unknown } | { ok: false; error: incremental.BuildError; value?: unknown }) {
307 return {
308 ...outcome,
309 changed: [
310 url.pathToFileURL(path.resolve("src/page.marko")).href,
311 url.pathToFileURL(path.resolve("src")).href + "/",
312 ],
313 stats: { duration: 12, ran: 1, reused: 2, written: 3, cacheBytes: 4, outputBytes: 5 },
314 watching: { file: 1 },
315 statusLine: "Watching 1 file",
316 };
317}
318
319/** a page's end of the socket, which reads the messages in order */
320async function connect(base: string, headers: Record<string, string>) {
321 // the client of node.js takes headers, which a browser's does not
322 const socket = new WebSocket(new URL("/.clover/socket", base.replace(/^http/, "ws")), { headers } as never);
323 const queue: incremental.DebuggerMessage[] = [];
324 const waiting: Array<() => void> = [];
325 socket.onmessage = (event) => {
326 queue.push(JSON.parse(event.data));
327 waiting.shift()?.();
328 };
329 await new Promise((resolve, reject) => {
330 socket.onopen = resolve;
331 socket.onerror = () => reject(new Error("the socket was refused"));
332 });
333 return {
334 /** the next message, or the next of a type */
335 async next<T extends incremental.DebuggerMessage[0] = incremental.DebuggerMessage[0]>(
336 type?: T,
337 ): Promise<Extract<incremental.DebuggerMessage, [T, ...unknown[]]>> {
338 while (true) {
339 while (queue.length === 0) await new Promise<void>((resolve) => waiting.push(resolve));
340 const message = queue.shift()!;
341 if (!type || message[0] === type) return message as Extract<incremental.DebuggerMessage, [T, ...unknown[]]>;
342 }
343 },
344 /** whether the server answered the close */
345 close: () =>
346 new Promise<boolean>((resolve) => {
347 socket.onclose = (event) => resolve(event.wasClean);
348 socket.close();
349 }),
350 };
351}
352
353import assert from "node:assert/strict";
354import * as net from "node:net";
355import * as path from "node:path";
356import * as consumers from "node:stream/consumers";
357import * as url from "node:url";
358import { test } from "vitest";
359import { UNWRAP } from "../assert.ts";
360import * as http from "../http.ts";
361import * as incremental from "../incremental.ts";
362import * as log from "../log.ts";
363import * as progress from "../progress.ts";