char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somdo web builds in a subprocessd1201e2

main
13.0 KiB306 linesraw
1import { assert, assertEquals, assertRejects, assertStringIncludes } from "@std/assert";
2import { basename, dirname, join } from "node:path";
3import { HOST_GLOBAL } from "@berry/api/host.ts";
4import { extensionWeb } from "./web.ts";
5import { tempDir } from "@berry/util";
6
7async function extension(files: Record<string, string>, run: (dir: string) => Promise<void>) {
8  await using temporary = await tempDir({ prefix: "berry-web-" });
9  const dir = temporary.path;
10  for (const [name, text] of Object.entries(files)) {
11    await Deno.mkdir(dirname(join(dir, name)), { recursive: true });
12    await Deno.writeTextFile(join(dir, name), text);
13  }
14  await run(dir);
15}
16
17const importBuilt = (js: string) =>
18  import(`data:text/javascript;base64,${btoa(unescape(encodeURIComponent(js)))}`);
19
20Deno.test("a frontend cache survives a new builder and invalidates imported inputs", () =>
21  extension({
22    "source/web.tsx": `import value from "../shared.ts"; import "./web.css";
23      import asset from "./large.wasm?url"; export default { value, asset };`,
24    "shared.ts": `export default "first";`,
25    "source/web.css": ".probe { color: red }",
26    "source/large.wasm": "\0asm".padEnd(64 * 1024, "x"),
27    "source/mod.ts": "export default 1;",
28    "source/web.test.ts": "export default 1;",
29  }, async (dir) => {
30    await using cache = await tempDir();
31    const sources: string[] = [];
32    const fresh = () => extensionWeb({ cacheDirectory: cache.path });
33    const build = (web = fresh()) =>
34      web.build("probe", entry, (source) => {
35        sources.push(source);
36      });
37    const entry = join(dir, "source/web.tsx");
38    await using api = await tempDir({ dir: join(Deno.cwd(), "api"), prefix: ".web-cache-test-" });
39    const unrelatedApi = join(api.path, "unrelated.ts");
40    await Deno.writeTextFile(unrelatedApi, "export default 1;");
41    const importedApi = join(api.path, "shared.ts");
42    await Deno.writeTextFile(importedApi, `export default "first";`);
43    await Deno.writeTextFile(
44      join(dir, "shared.ts"),
45      `export { default } from "@berry/api/${basename(api.path)}/shared.ts";`,
46    );
47    const web = fresh();
48    const first = await build(web);
49    const restarted = fresh();
50    assertEquals(await build(restarted), first);
51    assertEquals(
52      await restarted.serve(first.module)!.text(),
53      await web.serve(first.module)!.text(),
54    );
55    assertEquals(
56      await restarted.serve(first.styles[0])!.text(),
57      await web.serve(first.styles[0])!.text(),
58    );
59    const js = await restarted.serve(first.module)!.text();
60    const asset = js.match(/new URL\("([^"]+)", import\.meta\.url\)/)![1];
61    const path = new URL(asset, `http://berry${first.module}`).pathname;
62    assertEquals(await restarted.serve(path)!.bytes(), await web.serve(path)!.bytes());
63    assertEquals(sources, ["build", "cache"]);
64
65    await Deno.writeTextFile(join(dir, "source/mod.ts"), "export default 2;");
66    await Deno.writeTextFile(join(dir, "source/web.test.ts"), "export default 2;");
67    await Deno.writeTextFile(unrelatedApi, "export default 2;");
68    assertEquals(await build(), first);
69    assertEquals(sources.at(-1), "cache");
70
71    await Deno.writeTextFile(importedApi, `export default "api changed";`);
72    const apiChanged = fresh();
73    const apiBuilt = await build(apiChanged);
74    assertStringIncludes(await apiChanged.serve(apiBuilt.module)!.text(), "api changed");
75    assertEquals(sources.at(-1), "build");
76
77    const shared = join(dir, "shared.ts");
78    const mtime = Deno.statSync(shared).mtime!;
79    await Deno.writeTextFile(shared, `export default "other";`);
80    await Deno.utime(shared, mtime, mtime);
81    const changed = fresh();
82    const second = await build(changed);
83    assert(second.module !== first.module);
84    assertStringIncludes(await changed.serve(second.module)!.text(), "other");
85    assertEquals(sources.at(-1), "build");
86
87    await Deno.writeTextFile(join(dir, "source/web.css"), ".probe { color: blue }");
88    const third = await build();
89    assert(third.styles[0] !== second.styles[0]);
90    await Deno.writeTextFile(join(dir, "source/large.wasm"), "\0asm".padEnd(64 * 1024, "y"));
91    assert((await build()).module !== third.module);
92  }));
93
94Deno.test("CSS imports and inlined URL assets invalidate frontend caches", () =>
95  extension({
96    "web.tsx": `import "./web.css"; export default {};`,
97    "web.css": `@import "./colors.css"; .probe { background: url("./dot.svg") }`,
98    "colors.css": ".probe { color: red }",
99    "dot.svg": `<svg xmlns="http://www.w3.org/2000/svg"><circle r="1" fill="red"/></svg>`,
100  }, async (dir) => {
101    await using cache = await tempDir();
102    const sources: string[] = [];
103    const build = () =>
104      extensionWeb({ cacheDirectory: cache.path }).build(
105        "probe",
106        join(dir, "web.tsx"),
107        (source) => {
108          sources.push(source);
109        },
110      );
111    const first = await build();
112    assertEquals(await build(), first);
113    assertEquals(sources, ["build", "cache"]);
114    await Deno.writeTextFile(join(dir, "colors.css"), ".probe { color: blue }");
115    const second = await build();
116    assert(second.styles[0] !== first.styles[0]);
117    assertEquals(sources.at(-1), "build");
118    await Deno.writeTextFile(
119      join(dir, "dot.svg"),
120      `<svg xmlns="http://www.w3.org/2000/svg"><circle r="1" fill="blue"/></svg>`,
121    );
122    assert((await build()).styles[0] !== second.styles[0]);
123    assertEquals(sources.at(-1), "build");
124  }));
125
126Deno.test("a broken or unwritable frontend cache does not prevent a build", () =>
127  extension({ "web.tsx": `export default "usable";` }, async (dir) => {
128    await using cache = await tempDir();
129    const entry = join(dir, "web.tsx");
130    const web = extensionWeb({ cacheDirectory: cache.path });
131    const first = await web.build("probe", entry);
132    for await (const file of Deno.readDir(cache.path)) {
133      const path = join(cache.path, file.name);
134      const text = await Deno.readTextFile(path);
135      await Deno.writeTextFile(path, text.replace("usable", "broken"));
136    }
137    const rebuilt = extensionWeb({ cacheDirectory: cache.path });
138    assertEquals(await rebuilt.build("probe", entry), first);
139    assertEquals(await rebuilt.serve(first.module)!.text(), await web.serve(first.module)!.text());
140    const blocked = join(dir, "blocked");
141    await Deno.writeTextFile(blocked, "not a directory");
142    const uncached = extensionWeb({ cacheDirectory: blocked });
143    const built = await uncached.build("probe", entry);
144    assertStringIncludes(await uncached.serve(built.module)!.text(), "usable");
145  }));
146
147Deno.test("frontends compile out of process, while cache hits start no compiler", () =>
148  extension({ "web.tsx": "export default 1;" }, async (dir) => {
149    await using cache = await tempDir();
150    const Command = Deno.Command;
151    let started = 0;
152    Deno.Command = class extends Command {
153      constructor(...args: ConstructorParameters<typeof Deno.Command>) {
154        super(...args);
155        started++;
156      }
157    };
158    try {
159      const web = extensionWeb({ cacheDirectory: cache.path });
160      const entry = join(dir, "web.tsx");
161      await web.build("probe", entry);
162      assert(started > 0);
163      const previous = started;
164      await web.build("probe", entry);
165      assertEquals(started, previous);
166    } finally {
167      Deno.Command = Command;
168    }
169  }));
170
171Deno.test("a built frontend uses the app's instances of the shared modules", () =>
172  extension({
173    "web.tsx": `
174      import { Signal } from "@char/aftercare";
175      import * as api from "@berry/api/web.ts";
176      import "./web.css";
177      export default { Signal, api, view: <b>hi</b> };
178    `,
179    "web.css": ".probe { color: red }",
180  }, async (dir) => {
181    const web = extensionWeb();
182    const { module, styles } = await web.build("probe", join(dir, "web.tsx"));
183    const js = await web.serve(module)!.text();
184    assert(!js.includes(`from "@char/aftercare"`), "no import of its own copy");
185
186    class Signal {}
187    const api = { code() {} };
188    (globalThis as Record<string, unknown>)[HOST_GLOBAL] = {
189      "@char/aftercare": { Signal },
190      "@char/aftercare/jsx-runtime": {
191        jsx: (tag: string) => `<${tag}>`,
192        jsxs: (tag: string) => `<${tag}>`,
193      },
194      "@berry/api/web.ts": api,
195    };
196    try {
197      const built = (await importBuilt(js)).default;
198      assert(built.Signal === Signal);
199      assert(built.api === api);
200      assertEquals(built.view, "<b>");
201    } finally {
202      delete (globalThis as Record<string, unknown>)[HOST_GLOBAL];
203    }
204
205    assertEquals(styles.length, 1);
206    const css = web.serve(styles[0])!;
207    assertEquals(css.headers.get("content-type"), "text/css; charset=utf-8");
208    assertStringIncludes(await css.text(), ".probe");
209  }));
210
211Deno.test("a rebuild moves to a new url, and a failed one keeps the last", () =>
212  extension({ "web.tsx": `export default 1;` }, async (dir) => {
213    const web = extensionWeb();
214    const first = await web.build("probe", join(dir, "web.tsx"));
215    assertEquals(first.styles, []);
216    assertEquals(
217      web.serve(first.module)!.headers.get("cache-control"),
218      "public, max-age=31536000, immutable",
219    );
220
221    await Deno.writeTextFile(join(dir, "web.tsx"), `export default 2;`);
222    const second = await web.build("probe", join(dir, "web.tsx"));
223    assert(second.module !== first.module);
224    assertEquals(web.serve(first.module), undefined);
225
226    await Deno.writeTextFile(join(dir, "web.tsx"), `export default (;`);
227    await assertRejects(() => web.build("probe", join(dir, "web.tsx")), Error, "web.tsx");
228    assertStringIncludes(await web.serve(second.module)!.text(), "2");
229
230    await Deno.writeTextFile(join(dir, "web.tsx"), `export default 3;`);
231    const third = await web.build("probe", join(dir, "web.tsx"));
232    assertStringIncludes(await web.serve(third.module)!.text(), "3");
233  }));
234
235Deno.test("a frontend's large files are served beside its module rather than inlined into it", () =>
236  extension({
237    "web.tsx": `export default new URL("./big.wasm", import.meta.url).href;`,
238    "big.wasm": "\0asm".padEnd(64 * 1024, "x"),
239  }, async (dir) => {
240    await using cache = await tempDir();
241    const web = extensionWeb({ cacheDirectory: cache.path });
242    const { module } = await web.build("probe", join(dir, "web.tsx"));
243    const js = await web.serve(module)!.text();
244    assert(js.length < 16 * 1024, "the module only names it");
245    const named = js.match(/new URL\("([^"]+)", import\.meta\.url\)/)![1];
246    const file = web.serve(new URL(named, `http://berry${module}`).pathname)!;
247    assertEquals(file.headers.get("content-type"), "application/wasm");
248    assertEquals((await file.bytes()).length, 64 * 1024);
249    await Deno.writeTextFile(join(dir, "big.wasm"), "\0asm".padEnd(64 * 1024, "y"));
250    const changed = await extensionWeb({ cacheDirectory: cache.path }).build(
251      "probe",
252      join(dir, "web.tsx"),
253    );
254    assert(changed.module !== module);
255  }));
256
257Deno.test("a dynamic import is served beside the module and loaded only when imported", () =>
258  extension({
259    "web.tsx": `export default { load: () => import("./heavy.ts").then((m) => m.heavy) };`,
260    "heavy.ts": `export const heavy = "heavy:" + "x".repeat(32 * 1024);`,
261  }, async (dir) => {
262    await using cache = await tempDir();
263    const entry = join(dir, "web.tsx");
264    const first = await extensionWeb({ cacheDirectory: cache.path }).build("probe", entry);
265    const web = extensionWeb({ cacheDirectory: cache.path });
266    let source;
267    const { module } = await web.build("probe", entry, (value) => {
268      source = value;
269    });
270    assertEquals(source, "cache");
271    assertEquals(module, first.module);
272    assert((await web.serve(module)!.text()).length < 16 * 1024, "the module leaves it out");
273    // imported from files, as a page imports it from the server, so that relative imports resolve beside it
274    await using copy = await tempDir();
275    const served = async (path: string) => {
276      const file = join(copy.path, path);
277      await Deno.mkdir(join(file, ".."), { recursive: true });
278      await Deno.writeTextFile(file, await web.serve(path)!.text());
279      for (const [, name] of (await Deno.readTextFile(file)).matchAll(/import\("\.\/([^"]+)"\)/g)) {
280        await served(join(path, "..", name));
281      }
282    };
283    await served(module);
284    const built = (await import(`file://${join(copy.path, module)}`)).default;
285    assert((await built.load()).startsWith("heavy:"));
286    await Deno.writeTextFile(join(dir, "heavy.ts"), `export const heavy = "changed";`);
287    const changed = await web.build("probe", entry, (value) => {
288      source = value;
289    });
290    assertEquals(source, "build");
291    assert(changed.module !== module);
292  }));
293
294Deno.test("a change to a frontend's styles alone moves its stylesheet", () =>
295  extension({
296    "web.tsx": `import "./web.css"; export default 1;`,
297    "web.css": ".probe { color: red }",
298  }, async (dir) => {
299    const web = extensionWeb();
300    const first = await web.build("probe", join(dir, "web.tsx"));
301    await Deno.writeTextFile(join(dir, "web.css"), ".probe { color: blue }");
302    const second = await web.build("probe", join(dir, "web.tsx"));
303    assert(second.styles[0] !== first.styles[0]);
304    assertStringIncludes(await web.serve(second.styles[0])!.text(), "blue");
305    assertEquals(web.serve(first.styles[0]), undefined);
306  }));