@akanjs/devkit 2.4.1-rc.3 → 2.4.1-rc.5
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/akanApp/akanApp.host.test.ts +199 -2
- package/akanApp/akanApp.host.ts +399 -9
- package/executors.test.ts +60 -0
- package/executors.ts +11 -0
- package/frontendBuild/fontOptimizer.test.ts +111 -0
- package/frontendBuild/fontOptimizer.ts +102 -17
- package/frontendBuild/hmrWatcher.test.ts +191 -0
- package/frontendBuild/hmrWatcher.ts +176 -5
- package/frontendBuild/index.ts +1 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +280 -0
- package/frontendBuild/sourceMtimeIndex.ts +326 -0
- package/incrementalBuilder/buildBatch.proc.ts +34 -1
- package/incrementalBuilder/buildBatchProtocol.ts +13 -2
- package/incrementalBuilder/builderChannel.test.ts +144 -0
- package/incrementalBuilder/builderChannel.ts +72 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +88 -3
- package/incrementalBuilder/incrementalBuilder.host.ts +50 -3
- package/incrementalBuilder/incrementalBuilder.proc.ts +93 -34
- package/integration/devStability.integration.test.ts +260 -101
- package/integration/devStabilityHarness.test.ts +111 -0
- package/integration/devStabilityHarness.ts +528 -39
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|