@akanjs/devkit 2.4.1-rc.2 → 2.4.1-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/executors.test.ts CHANGED
@@ -271,6 +271,66 @@ describe("Workspace and app executor environment contracts", () => {
271
271
  expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
272
272
  });
273
273
 
274
+ describe("getDevPort", () => {
275
+ const makeWorkspaceWithApps = async (names: string[]) => {
276
+ const root = await makeTempRoot();
277
+ process.env.AKAN_PUBLIC_REPO_NAME = "repo";
278
+ process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
279
+ process.env.AKAN_PUBLIC_ENV = "local";
280
+ process.env.PORT_OFFSET = "0";
281
+ await writeJson(path.join(root, "package.json"), rootPackageJson());
282
+ for (const name of names) {
283
+ await mkdir(path.join(root, "apps", name), { recursive: true });
284
+ await writeFile(path.join(root, "apps", name, "akan.config.ts"), "export default {};\n");
285
+ }
286
+ // `AppExecutor.from` memoises by name, so each test needs names no other test has used.
287
+ return new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
288
+ };
289
+
290
+ test("derives the port from the app's position in the sorted apps listing", async () => {
291
+ const workspace = await makeWorkspaceWithApps(["port-a", "port-b"]);
292
+
293
+ expect(await AppExecutor.from(workspace, "port-a").getDevPort()).toBe(8282);
294
+ expect(await AppExecutor.from(workspace, "port-b").getDevPort()).toBe(8283);
295
+ });
296
+
297
+ test("moves when another app appears before it, which is why pinning exists", async () => {
298
+ const workspace = await makeWorkspaceWithApps(["drift-b"]);
299
+ const app = AppExecutor.from(workspace, "drift-b");
300
+ expect(await app.getDevPort()).toBe(8282);
301
+
302
+ // Sorts ahead of `drift-b`, so the same app now answers with a different port — and a dev host
303
+ // recomputes this on every restart.
304
+ await mkdir(path.join(workspace.workspaceRoot, "apps/drift-a"), { recursive: true });
305
+ await writeFile(path.join(workspace.workspaceRoot, "apps/drift-a/akan.config.ts"), "export default {};\n");
306
+
307
+ expect(await app.getDevPort()).toBe(8283);
308
+ });
309
+
310
+ test("AKAN_DEV_PORT pins it, and survives an app appearing before it", async () => {
311
+ const workspace = await makeWorkspaceWithApps(["pin-b"]);
312
+ const app = AppExecutor.from(workspace, "pin-b");
313
+ process.env.AKAN_DEV_PORT = "12345";
314
+
315
+ expect(await app.getDevPort()).toBe(12345);
316
+
317
+ await mkdir(path.join(workspace.workspaceRoot, "apps/pin-a"), { recursive: true });
318
+ await writeFile(path.join(workspace.workspaceRoot, "apps/pin-a/akan.config.ts"), "export default {};\n");
319
+
320
+ expect(await app.getDevPort()).toBe(12345);
321
+ });
322
+
323
+ test("ignores an unusable AKAN_DEV_PORT rather than binding a nonsense port", async () => {
324
+ const workspace = await makeWorkspaceWithApps(["bad-a"]);
325
+ const app = AppExecutor.from(workspace, "bad-a");
326
+
327
+ for (const value of ["0", "-1", "nope", "", "70000", "8282.5"]) {
328
+ process.env.AKAN_DEV_PORT = value;
329
+ expect(await app.getDevPort()).toBe(8282);
330
+ }
331
+ });
332
+ });
333
+
274
334
  test("accepts metadata route exports during page key discovery", async () => {
275
335
  const root = await makeTempRoot();
276
336
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
package/executors.ts CHANGED
@@ -1256,7 +1256,18 @@ export class AppExecutor extends SysExecutor {
1256
1256
  getEnv() {
1257
1257
  return WorkspaceExecutor.getBaseDevEnv().env;
1258
1258
  }
1259
+ /**
1260
+ * This app's dev port, derived from its position in the sorted `apps/` listing so several apps can run
1261
+ * at once without colliding.
1262
+ *
1263
+ * `AKAN_DEV_PORT` pins it instead, because the derived value *moves*: the index shifts whenever any other
1264
+ * app directory appears or disappears, and a dev host recomputes this on every restart. So a session that
1265
+ * adds an app relands its dev server on a different port, and anything that reserved a port relative to
1266
+ * the old one is left pointing at nothing.
1267
+ */
1259
1268
  async getDevPort() {
1269
+ const pinned = Number(process.env.AKAN_DEV_PORT);
1270
+ if (Number.isInteger(pinned) && pinned > 0 && pinned <= 65_535) return pinned;
1260
1271
  const basePort = 8282;
1261
1272
  const appNames = (await this.workspace.getApps()).sort((a, b) => a.localeCompare(b));
1262
1273
  const appIndex = Math.max(appNames.indexOf(this.name), 0);
@@ -0,0 +1,111 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type { App } from "../commandDecorators";
6
+ import { FontOptimizer } from "./fontOptimizer";
7
+
8
+ const SOURCE_FONT = path.resolve(import.meta.dir, "../../../../libs/shared/public/fonts/Assistant-Regular.woff2");
9
+
10
+ const tempRoots: string[] = [];
11
+
12
+ const makeApp = async (layoutSource: string) => {
13
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-devkit-font-"));
14
+ tempRoots.push(root);
15
+ const cwdPath = path.join(root, "apps/demo");
16
+ await mkdir(path.join(cwdPath, "page"), { recursive: true });
17
+ await mkdir(path.join(cwdPath, "public/fonts"), { recursive: true });
18
+ await writeFile(path.join(cwdPath, "page/_layout.tsx"), layoutSource);
19
+ await Bun.write(path.join(cwdPath, "public/fonts/Assistant-Regular.woff2"), Bun.file(SOURCE_FONT));
20
+ const app = {
21
+ cwdPath,
22
+ dist: { cwdPath: path.join(root, "dist/apps/demo") },
23
+ workspace: { workspaceRoot: root },
24
+ getPageKeys: async () => ["./_layout.tsx"],
25
+ verbose: () => undefined,
26
+ logger: { warn: () => undefined },
27
+ } as unknown as App;
28
+ return { app, cwdPath };
29
+ };
30
+
31
+ const layoutWith = (extra = "") => `
32
+ export const fonts = [
33
+ {
34
+ name: "Assistant",${extra}
35
+ paths: [{ src: "/fonts/Assistant-Regular.woff2", weight: 400 }],
36
+ },
37
+ ];
38
+ export default function Layout() {
39
+ return null;
40
+ }
41
+ `;
42
+
43
+ const optimize = (app: App) => new FontOptimizer(app, "start").optimize();
44
+
45
+ afterEach(async () => {
46
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
47
+ });
48
+
49
+ describe("FontOptimizer cache", () => {
50
+ test("reuses subset output instead of resubsetting an unchanged font", async () => {
51
+ const { app } = await makeApp(layoutWith());
52
+
53
+ const first = await optimize(app);
54
+ expect(first.files).toHaveLength(1);
55
+ expect(first.css).toContain("@font-face");
56
+ const writtenAt = (await stat(first.files[0])).mtimeMs;
57
+
58
+ const second = await optimize(app);
59
+ expect(second.files).toEqual(first.files);
60
+ expect(second.css).toBe(first.css);
61
+ expect(second.fonts).toEqual(first.fonts);
62
+ // The output was reused, not rewritten — the whole point of the cache.
63
+ expect((await stat(second.files[0])).mtimeMs).toBe(writtenAt);
64
+ });
65
+
66
+ test("resubsets when the source font file changes", async () => {
67
+ const { app, cwdPath } = await makeApp(layoutWith());
68
+ const first = await optimize(app);
69
+ const firstBytes = await Bun.file(first.files[0]).bytes();
70
+
71
+ // Swapped for a different real font rather than corrupted, so the resubset itself still succeeds.
72
+ const sourcePath = path.join(cwdPath, "public/fonts/Assistant-Regular.woff2");
73
+ await Bun.write(sourcePath, Bun.file(path.resolve(path.dirname(SOURCE_FONT), "Assistant-Bold.woff2")));
74
+
75
+ const second = await optimize(app);
76
+ expect(second.files).toEqual(first.files);
77
+ expect(await Bun.file(second.files[0]).bytes()).not.toEqual(firstBytes);
78
+ });
79
+
80
+ test("resubsets when a config field changes without changing the output filename", async () => {
81
+ const { app, cwdPath } = await makeApp(layoutWith());
82
+ const first = await optimize(app);
83
+ const writtenAt = (await stat(first.files[0])).mtimeMs;
84
+
85
+ // `className` never feeds the output filename hash, so only the cache key can catch it.
86
+ await writeFile(path.join(cwdPath, "page/_layout.tsx"), layoutWith(`\n className: "font-brand",`));
87
+
88
+ const second = await optimize(app);
89
+ expect(second.files).toEqual(first.files);
90
+ expect(second.css).not.toBe(first.css);
91
+ expect(second.css).toContain(".font-brand");
92
+ expect((await stat(second.files[0])).mtimeMs).not.toBe(writtenAt);
93
+ });
94
+
95
+ test("ignores a cache whose output file is gone", async () => {
96
+ const { app } = await makeApp(layoutWith());
97
+ const first = await optimize(app);
98
+ await rm(first.files[0]);
99
+
100
+ const second = await optimize(app);
101
+ expect(second.files).toEqual(first.files);
102
+ expect(await Bun.file(second.files[0]).exists()).toBe(true);
103
+ });
104
+
105
+ test("skips route files that never mention fonts", async () => {
106
+ const { app } = await makeApp("export default function Layout() {\n return null;\n}\n");
107
+ const result = await optimize(app);
108
+ expect(result.fonts).toEqual([]);
109
+ expect(result.files).toEqual([]);
110
+ });
111
+ });
@@ -1,4 +1,4 @@
1
- import { mkdir } from "node:fs/promises";
1
+ import { mkdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type {
4
4
  ReactFont,
@@ -8,15 +8,7 @@ import type {
8
8
  ReactFontStyle,
9
9
  ReactFontSubset,
10
10
  } from "akanjs/client";
11
- import {
12
- type FontCategory,
13
- generateFontFace,
14
- getMetricsForFamily,
15
- readMetrics,
16
- resolveCategoryFallbacks,
17
- } from "fontaine";
18
- import { createFont, woff2 } from "fonteditor-core";
19
- import subsetFont from "subset-font";
11
+ import type { FontCategory } from "fontaine";
20
12
  import ts from "typescript";
21
13
  import type { App } from "../commandDecorators";
22
14
 
@@ -29,6 +21,17 @@ export interface OptimizeAppFontsResult {
29
21
  files: string[];
30
22
  }
31
23
 
24
+ /**
25
+ * Lets a boot that changed nothing about its fonts skip re-subsetting them. `files` is stored relative
26
+ * to the artifact root so the `build` and `start` roots keep independent, relocatable caches.
27
+ */
28
+ interface FontOptimizerCache {
29
+ version: number;
30
+ key: string;
31
+ css: string;
32
+ files: string[];
33
+ }
34
+
32
35
  export type FontOptimizerCommand = "build" | "start";
33
36
 
34
37
  export class FontOptimizer {
@@ -40,6 +43,7 @@ export class FontOptimizer {
40
43
  #woff2Ready: Promise<void> | null = null;
41
44
 
42
45
  static #ksX1001Text: string | null = null;
46
+ static readonly #cacheVersion = 1;
43
47
 
44
48
  constructor(app: App, command: FontOptimizerCommand = "start") {
45
49
  this.#app = app;
@@ -49,13 +53,83 @@ export class FontOptimizer {
49
53
 
50
54
  async optimize(): Promise<OptimizeAppFontsResult> {
51
55
  const fonts = await this.discoverFonts();
56
+ const cacheKey = await this.#buildCacheKey(fonts);
57
+ const cached = cacheKey ? await this.#readCache(cacheKey) : null;
58
+ if (cached) {
59
+ this.#app.verbose(`[font] reused ${cached.files.length} cached file(s); skipped subsetting`);
60
+ return { css: cached.css, fonts, files: cached.files };
61
+ }
52
62
  for (const font of fonts) {
53
63
  if (!this.#isFontOptimizationEnabled(font)) continue;
54
64
  await this.#optimizeFont(font);
55
65
  }
56
66
  const fontUtilityCss = this.#buildFontUtilityRules(fonts);
57
67
  if (fontUtilityCss) this.#cssParts.push(fontUtilityCss);
58
- return { css: this.#cssParts.join("\n"), fonts, files: this.#files };
68
+ const result = { css: this.#cssParts.join("\n"), fonts, files: this.#files };
69
+ if (cacheKey) await this.#writeCache(cacheKey, result);
70
+ return result;
71
+ }
72
+
73
+ get #cachePath() {
74
+ return path.join(this.#artifactRoot, "fontCache.json");
75
+ }
76
+
77
+ /**
78
+ * Null means "do not cache this run": a source we cannot stat is a source whose staleness we cannot
79
+ * detect, and the uncached path is also the one that warns about it.
80
+ */
81
+ async #buildCacheKey(fonts: ReactFont[]): Promise<string | null> {
82
+ const sources: unknown[] = [];
83
+ for (const font of fonts) {
84
+ if (!this.#isFontOptimizationEnabled(font)) continue;
85
+ for (const face of this.#getFontFaces(font)) {
86
+ const sourcePath = await this.#resolveFontSourcePath(face.src);
87
+ const stamp = sourcePath ? await this.#fileStamp(sourcePath) : null;
88
+ if (!stamp) return null;
89
+ sources.push({ optimizedSrc: face.optimizedSrc, ...stamp });
90
+ }
91
+ for (const filePath of font.subsetFiles ?? []) {
92
+ const abs = path.isAbsolute(filePath) ? filePath : path.join(this.#app.cwdPath, filePath);
93
+ const stamp = await this.#fileStamp(abs);
94
+ if (!stamp) return null;
95
+ sources.push({ subsetFile: filePath, ...stamp });
96
+ }
97
+ // `auto` derives the subset from app source text, which no font config hash can capture.
98
+ if (this.#getFontSubsets(font).includes("auto"))
99
+ sources.push({ autoSubsetText: this.#hashFontConfig(await this.#collectAutoSubsetText()) });
100
+ }
101
+ return this.#hashFontConfig({ version: FontOptimizer.#cacheVersion, fonts, sources });
102
+ }
103
+
104
+ async #fileStamp(filePath: string): Promise<{ mtimeMs: number; size: number } | null> {
105
+ try {
106
+ const stats = await stat(filePath);
107
+ return { mtimeMs: Math.round(stats.mtimeMs), size: stats.size };
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ async #readCache(key: string): Promise<{ css: string; files: string[] } | null> {
114
+ const cache = (await Bun.file(this.#cachePath)
115
+ .json()
116
+ .catch(() => null)) as FontOptimizerCache | null;
117
+ if (cache?.version !== FontOptimizer.#cacheVersion || cache.key !== key) return null;
118
+ const files = cache.files.map((relativePath) => path.join(this.#artifactRoot, relativePath));
119
+ for (const filePath of files) {
120
+ if (!(await Bun.file(filePath).exists())) return null;
121
+ }
122
+ return { css: cache.css, files };
123
+ }
124
+
125
+ async #writeCache(key: string, result: OptimizeAppFontsResult): Promise<void> {
126
+ const cache: FontOptimizerCache = {
127
+ version: FontOptimizer.#cacheVersion,
128
+ key,
129
+ css: result.css,
130
+ files: result.files.map((filePath) => path.relative(this.#artifactRoot, filePath)),
131
+ };
132
+ await Bun.write(this.#cachePath, JSON.stringify(cache));
59
133
  }
60
134
 
61
135
  async discoverFonts(): Promise<ReactFont[]> {
@@ -66,7 +140,11 @@ export class FontOptimizer {
66
140
  const filePath = path.resolve(this.#app.cwdPath, "page", key);
67
141
  const file = Bun.file(filePath);
68
142
  if (!(await file.exists())) return;
69
- fonts.push(...this.#extractFontsExport(await file.text(), filePath));
143
+ const source = await file.text();
144
+ // A declaration named `fonts` cannot exist in text that never mentions it, and parsing the
145
+ // route files that never declare one is what a cached optimize() otherwise spends its time on.
146
+ if (!source.includes("fonts")) return;
147
+ fonts.push(...this.#extractFontsExport(source, filePath));
70
148
  }),
71
149
  );
72
150
  return this.#dedupeFonts(fonts);
@@ -85,10 +163,7 @@ export class FontOptimizer {
85
163
  await mkdir(path.dirname(outputPath), { recursive: true });
86
164
 
87
165
  const sourceBuffer = Buffer.from(await Bun.file(sourcePath).arrayBuffer());
88
- const outputBuffer =
89
- font.subset === false
90
- ? await this.#convertToWoff2(sourceBuffer, sourcePath)
91
- : await subsetFont(sourceBuffer, await this.#getSubsetText(font), { targetFormat: "woff2" });
166
+ const outputBuffer = await this.#buildFontBuffer(font, sourceBuffer, sourcePath);
92
167
  await Bun.write(outputPath, outputBuffer);
93
168
  this.#files.push(outputPath);
94
169
 
@@ -255,14 +330,23 @@ export class FontOptimizer {
255
330
  return null;
256
331
  }
257
332
 
333
+ /** `subset-font`, `fonteditor-core` and `fontaine` are imported here rather than at module scope so a
334
+ * cache hit — the common case once the cache exists — loads none of them. */
335
+ async #buildFontBuffer(font: ReactFont, sourceBuffer: Buffer, sourcePath: string) {
336
+ if (font.subset === false) return this.#convertToWoff2(sourceBuffer, sourcePath);
337
+ const { default: subsetFont } = await import("subset-font");
338
+ return subsetFont(sourceBuffer, await this.#getSubsetText(font), { targetFormat: "woff2" });
339
+ }
340
+
258
341
  async #convertToWoff2(buffer: Buffer, sourcePath: string) {
342
+ const { createFont } = await import("fonteditor-core");
259
343
  await this.#initWoff2();
260
344
  const font = createFont(buffer, { type: this.#getFontType(sourcePath, buffer) });
261
345
  return font.write({ type: "woff2", toBuffer: true });
262
346
  }
263
347
 
264
348
  async #initWoff2() {
265
- this.#woff2Ready ??= woff2.init().then(() => undefined);
349
+ this.#woff2Ready ??= import("fonteditor-core").then(({ woff2 }) => woff2.init()).then(() => undefined);
266
350
  return this.#woff2Ready;
267
351
  }
268
352
 
@@ -363,6 +447,7 @@ export class FontOptimizer {
363
447
 
364
448
  async #buildFontaineFallbackCss(font: ReactFont, face: ReactFontFace, outputPath: string) {
365
449
  if (font.adjustFontFallback === false) return "";
450
+ const { generateFontFace, getMetricsForFamily, readMetrics, resolveCategoryFallbacks } = await import("fontaine");
366
451
  const metrics = await readMetrics(outputPath).catch(() => null);
367
452
  if (!metrics) return "";
368
453
  const fallbacks = resolveCategoryFallbacks({
@@ -0,0 +1,191 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type { Logger } from "akanjs/common";
6
+ import { type ChangeBatch, HmrWatcher } from "./hmrWatcher";
7
+
8
+ /**
9
+ * These run against the real Bun watcher rather than a fake, because the behaviour under test only
10
+ * exists there: Bun 1.3.14's recursive `fs.watch` reports about one path per ~200ms coalescing window and
11
+ * discards the rest (`local/optimize-resource/06-watcher-dropped-event.md`). A fake that delivered every
12
+ * event would pass no matter what the watcher did with them.
13
+ */
14
+ const STREAM_WARMUP_MS = 600;
15
+ const SETTLE_MS = 1_500;
16
+ const TEST_TIMEOUT_MS = 15_000;
17
+
18
+ const started: HmrWatcher[] = [];
19
+ const roots: string[] = [];
20
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
21
+ /** Every level `HmrWatcher` can reach, so adding a log line cannot fail a test for the wrong reason. */
22
+ const silentLogger = {
23
+ trace: () => undefined,
24
+ verbose: () => undefined,
25
+ debug: () => undefined,
26
+ log: () => undefined,
27
+ info: () => undefined,
28
+ warn: () => undefined,
29
+ error: () => undefined,
30
+ } as unknown as Logger;
31
+
32
+ const makeRoot = async () => {
33
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-hmr-watcher-"));
34
+ roots.push(root);
35
+ return root;
36
+ };
37
+
38
+ const seed = async (root: string, rel: string, content = "export const x = 1;\n") => {
39
+ const abs = path.join(root, rel);
40
+ await mkdir(path.dirname(abs), { recursive: true });
41
+ await writeFile(abs, content);
42
+ return abs;
43
+ };
44
+
45
+ /** Start watching and let the FSEvents stream come up; events raised before it does are not delivered. */
46
+ const watch = async (root: string) => {
47
+ const batches: ChangeBatch[] = [];
48
+ const watcher = new HmrWatcher({
49
+ roots: [root],
50
+ logger: silentLogger,
51
+ onBatch: (batch) => void batches.push(batch),
52
+ });
53
+ started.push(watcher);
54
+ await watcher.start();
55
+ await sleep(STREAM_WARMUP_MS);
56
+ return { watcher, batches, seen: () => new Set(batches.flatMap((batch) => batch.files)) };
57
+ };
58
+
59
+ afterEach(async () => {
60
+ for (const watcher of started.splice(0)) watcher.stop();
61
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
62
+ });
63
+
64
+ describe("HmrWatcher", () => {
65
+ test(
66
+ "reports every file of a save-all",
67
+ async () => {
68
+ const root = await makeRoot();
69
+ const files = await Promise.all([0, 1, 2, 3, 4].map((i) => seed(root, `lib/File${i}.ts`)));
70
+ const { batches, seen, watcher } = await watch(root);
71
+
72
+ // No gaps between writes, so they share one coalescing window and Bun names at most one of them.
73
+ for (const [i, abs] of files.entries()) await writeFile(abs, `export const x = ${i}00;\n`);
74
+ await sleep(SETTLE_MS);
75
+
76
+ expect(batches.length).toBeGreaterThan(0);
77
+ for (const abs of files) expect([...seen()]).toContain(abs);
78
+ // Not asserted as non-zero: the point is that the outcome above holds whether or not Bun drops
79
+ // events, so this suite keeps passing if the upstream defect is ever fixed. The counter is the
80
+ // signal for when the compensation can be removed.
81
+ expect(watcher.unreportedChanges).toBeGreaterThanOrEqual(0);
82
+ },
83
+ TEST_TIMEOUT_MS,
84
+ );
85
+
86
+ test(
87
+ "reports a save that lands in the same window as a build's artifact burst",
88
+ async () => {
89
+ const root = await makeRoot();
90
+ const source = await seed(root, "lib/a.ts");
91
+ const { seen, batches } = await watch(root);
92
+
93
+ // What every build ends with: a burst under `.akan/`, which the classifier ignores. Before this
94
+ // was handled, the burst was the one path Bun reported and the save right after it was invisible.
95
+ const artifactDir = path.join(root, ".akan", "artifact", "server");
96
+ await mkdir(artifactDir, { recursive: true });
97
+ for (let i = 0; i < 60; i++) await writeFile(path.join(artifactDir, `chunk-${i}.js`), "x".repeat(8192));
98
+ await writeFile(path.join(artifactDir, "pages.js"), "y".repeat(4 * 1024 * 1024));
99
+ await writeFile(source, "export const x = 999;\n");
100
+ await sleep(SETTLE_MS);
101
+
102
+ expect([...seen()]).toContain(source);
103
+ // Exactly one batch for one save. Bun does deliver a real event for some paths the scan has already
104
+ // emitted, and counting both produced a second generation and a second build for no change.
105
+ expect(batches.filter((batch) => batch.files.includes(source))).toHaveLength(1);
106
+ },
107
+ TEST_TIMEOUT_MS,
108
+ );
109
+
110
+ test(
111
+ "raises no batch for build output alone",
112
+ async () => {
113
+ const root = await makeRoot();
114
+ await seed(root, "lib/a.ts");
115
+ const { batches } = await watch(root);
116
+
117
+ const artifactDir = path.join(root, ".akan", "artifact");
118
+ await mkdir(artifactDir, { recursive: true });
119
+ for (let i = 0; i < 20; i++) await writeFile(path.join(artifactDir, `chunk-${i}.js`), "x".repeat(4096));
120
+ await sleep(SETTLE_MS);
121
+
122
+ // The verification scan is triggered by ignored paths, so it must not invent work from them either.
123
+ expect(batches).toEqual([]);
124
+ },
125
+ TEST_TIMEOUT_MS,
126
+ );
127
+
128
+ test(
129
+ "classifies a stylesheet edit as css and a config edit as config",
130
+ async () => {
131
+ const root = await makeRoot();
132
+ const style = await seed(root, "ui/app.css", ".a{color:red}\n");
133
+ const config = await seed(root, "akan.config.ts", "export default {};\n");
134
+ const { batches } = await watch(root);
135
+
136
+ await writeFile(style, ".a{color:blue}\n");
137
+ await sleep(400);
138
+ await writeFile(config, "export default { basePaths: [] };\n");
139
+ await sleep(SETTLE_MS);
140
+
141
+ const kinds = new Set(batches.flatMap((batch) => [...batch.kinds]));
142
+ expect(kinds.has("css")).toBe(true);
143
+ expect(kinds.has("config")).toBe(true);
144
+ },
145
+ TEST_TIMEOUT_MS,
146
+ );
147
+
148
+ test(
149
+ "stops reporting after stop()",
150
+ async () => {
151
+ const root = await makeRoot();
152
+ const source = await seed(root, "lib/a.ts");
153
+ const { watcher, batches } = await watch(root);
154
+
155
+ watcher.stop();
156
+ await writeFile(source, "export const x = 5;\n");
157
+ await sleep(SETTLE_MS);
158
+
159
+ expect(batches).toEqual([]);
160
+ },
161
+ TEST_TIMEOUT_MS,
162
+ );
163
+
164
+ test.skipIf(process.getuid?.() === 0)(
165
+ "warns at startup when part of the tree cannot be read",
166
+ async () => {
167
+ const root = await makeRoot();
168
+ const hidden = await seed(root, "locked/b.ts");
169
+ await chmod(path.dirname(hidden), 0o000);
170
+ const warnings: string[] = [];
171
+
172
+ const watcher = new HmrWatcher({
173
+ roots: [root],
174
+ logger: { ...silentLogger, warn: (msg: string) => void warnings.push(msg) } as unknown as Logger,
175
+ onBatch: () => undefined,
176
+ });
177
+ started.push(watcher);
178
+ await watcher.start();
179
+ // Restored before asserting, not after: a failed assertion would otherwise leave a directory `rm`
180
+ // cannot traverse, and the leak would fail the *next* test instead of this one.
181
+ await chmod(path.dirname(hidden), 0o755);
182
+
183
+ // At startup rather than at the first save, because an unreadable root means edits under it never
184
+ // rebuild — waiting for a save to reveal that means waiting for the save that silently does nothing.
185
+ expect(warnings).toHaveLength(1);
186
+ expect(warnings[0]).toContain("will not rebuild");
187
+ expect(warnings[0]).toContain("EACCES");
188
+ },
189
+ TEST_TIMEOUT_MS,
190
+ );
191
+ });