@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/akanApp/akanApp.host.test.ts +283 -2
- package/akanApp/akanApp.host.ts +578 -12
- 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 +194 -0
- package/incrementalBuilder/buildBatchProtocol.ts +53 -0
- package/incrementalBuilder/buildBatchRunner.ts +85 -0
- package/incrementalBuilder/builderReply.test.ts +73 -0
- package/incrementalBuilder/builderReply.ts +30 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +199 -9
- package/incrementalBuilder/incrementalBuilder.host.ts +119 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +260 -170
- package/integration/devStability.integration.test.ts +308 -78
- package/integration/devStabilityHarness.test.ts +111 -0
- package/integration/devStabilityHarness.ts +555 -40
- package/local/optimize-resource/ipcprobe/child.ts +1 -0
- package/local/optimize-resource/ipcprobe/parent.ts +11 -0
- package/package.json +2 -2
|
@@ -1,17 +1,32 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Logger } from "akanjs/common";
|
|
6
|
+
import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangeAction } from "akanjs/server";
|
|
7
|
+
import type { App } from "../commandDecorators";
|
|
3
8
|
import {
|
|
9
|
+
AkanAppHost,
|
|
10
|
+
BackendImportGraph,
|
|
4
11
|
backendRestartReasonFromMessage,
|
|
5
12
|
buildStatusReplaySequence,
|
|
6
13
|
createBackendBuildStatus,
|
|
14
|
+
decideBuilderRssRecycle,
|
|
15
|
+
decideBuilderRssSettle,
|
|
16
|
+
decideIdleSuspend,
|
|
17
|
+
hasAnyBuildFailure,
|
|
7
18
|
hasBuildFailureForGeneration,
|
|
8
19
|
isLegacyBackendFallbackFile,
|
|
9
20
|
mergeBackendRestartReasons,
|
|
10
21
|
mergeInvalidateMessages,
|
|
11
22
|
normalizeBackendReportedGeneration,
|
|
23
|
+
resolveIdleSuspendMs,
|
|
12
24
|
shouldAbandonBackendRecovery,
|
|
25
|
+
shouldAbandonBuilderRssCeiling,
|
|
13
26
|
shouldMarkBuildPhaseRecovered,
|
|
14
27
|
shouldQueueBuildStatusReplay,
|
|
28
|
+
shouldRefreshConfigOnIdleWake,
|
|
29
|
+
shouldRelayRecycledFrontendState,
|
|
15
30
|
shouldReplaceLastGoodMessage,
|
|
16
31
|
shouldRestartBackendByDevPlan,
|
|
17
32
|
shouldRestartBuilderByDevPlan,
|
|
@@ -141,6 +156,192 @@ describe("last-good frontend helpers", () => {
|
|
|
141
156
|
});
|
|
142
157
|
});
|
|
143
158
|
|
|
159
|
+
describe("builder rss recycle", () => {
|
|
160
|
+
const ceiling = 1_200 * 1024 * 1024;
|
|
161
|
+
const decide = (over: Partial<Parameters<typeof decideBuilderRssRecycle>[0]>) =>
|
|
162
|
+
decideBuilderRssRecycle({
|
|
163
|
+
rssBytes: ceiling + 1,
|
|
164
|
+
ceilingBytes: ceiling,
|
|
165
|
+
buildFailed: false,
|
|
166
|
+
msSinceLastRecycle: null,
|
|
167
|
+
...over,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("recycles an idle builder that crossed the ceiling", () => {
|
|
171
|
+
expect(decide({})).toBe("recycle");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("leaves a builder under the ceiling alone", () => {
|
|
175
|
+
expect(decide({ rssBytes: ceiling - 1 })).toBe("below-ceiling");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("does nothing when no ceiling is configured", () => {
|
|
179
|
+
expect(decide({ ceilingBytes: null })).toBe("unbounded");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Rebooting on a generation whose build failed strands the dev server: the replacement hits the
|
|
183
|
+
// same compile error and exits before builder-ready.
|
|
184
|
+
test("defers while the current generation has a failing build", () => {
|
|
185
|
+
expect(decide({ buildFailed: true })).toBe("build-failed");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("refuses a second recycle inside the minimum interval", () => {
|
|
189
|
+
expect(decide({ msSinceLastRecycle: 5_000 })).toBe("too-soon");
|
|
190
|
+
expect(decide({ msSinceLastRecycle: 31_000 })).toBe("recycle");
|
|
191
|
+
expect(decide({ msSinceLastRecycle: 5_000, minIntervalMs: 1_000 })).toBe("recycle");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// An app whose fresh boot is already over the ceiling would otherwise be recycled forever.
|
|
195
|
+
test("abandons the ceiling once recycling stops buying relief", () => {
|
|
196
|
+
expect(shouldAbandonBuilderRssCeiling(1)).toBe(false);
|
|
197
|
+
expect(shouldAbandonBuilderRssCeiling(2)).toBe(true);
|
|
198
|
+
expect(shouldAbandonBuilderRssCeiling(1, 1)).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Measured on Linux: the builder peaked at 522MiB and settled at 214MiB with no help, so a 400MiB
|
|
202
|
+
// ceiling recycled a process that was already back under it. The armed sample is always the peak.
|
|
203
|
+
describe("settle check before committing", () => {
|
|
204
|
+
test("waits when the builder is only modestly over the ceiling", () => {
|
|
205
|
+
expect(decideBuilderRssSettle({ rssBytes: 522, ceilingBytes: 400 })).toBe("wait-and-recheck");
|
|
206
|
+
expect(decideBuilderRssSettle({ rssBytes: 401, ceilingBytes: 400 })).toBe("wait-and-recheck");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// No purge is going to rescue a builder this far over, so waiting only delays the inevitable.
|
|
210
|
+
test("recycles immediately once far enough past the ceiling", () => {
|
|
211
|
+
expect(decideBuilderRssSettle({ rssBytes: 600, ceilingBytes: 400 })).toBe("recycle-now");
|
|
212
|
+
expect(decideBuilderRssSettle({ rssBytes: 900, ceilingBytes: 400 })).toBe("recycle-now");
|
|
213
|
+
expect(decideBuilderRssSettle({ rssBytes: 500, ceilingBytes: 400, hardMultiple: 1.2 })).toBe("recycle-now");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("readProcessRssBytes", () => {
|
|
218
|
+
test("reads this process's own rss", async () => {
|
|
219
|
+
const rssBytes = await AkanAppHost.readProcessRssBytes(process.pid);
|
|
220
|
+
if (rssBytes === null) throw new Error("expected to read this process's own rss");
|
|
221
|
+
// Loose bounds on purpose: the point is that it read a real number from the OS, not which number.
|
|
222
|
+
expect(rssBytes).toBeGreaterThan(1024 * 1024);
|
|
223
|
+
expect(rssBytes).toBeLessThan(64 * 1024 * 1024 * 1024);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Null rather than 0, because callers must treat an unreadable pid as "no new information" — a 0
|
|
227
|
+
// would read as "settled below the ceiling" and cancel a recycle that should happen.
|
|
228
|
+
test("returns null for a pid that does not exist", async () => {
|
|
229
|
+
expect(await AkanAppHost.readProcessRssBytes(2_147_483_646)).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("dev idle suspend", () => {
|
|
235
|
+
const decide = (over: Partial<Parameters<typeof decideIdleSuspend>[0]> = {}) =>
|
|
236
|
+
decideIdleSuspend({
|
|
237
|
+
enabled: true,
|
|
238
|
+
suspended: false,
|
|
239
|
+
builderReady: true,
|
|
240
|
+
backendReady: true,
|
|
241
|
+
buildFailed: false,
|
|
242
|
+
restartPending: false,
|
|
243
|
+
msSinceWake: null,
|
|
244
|
+
...over,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("suspends an idle dev server whose builder and backend are both up", () => {
|
|
248
|
+
expect(decide()).toBe("suspend");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("keeps build capacity while anything is still in motion", () => {
|
|
252
|
+
expect(decide({ enabled: false })).toBe("disabled");
|
|
253
|
+
expect(decide({ suspended: true })).toBe("already-suspended");
|
|
254
|
+
expect(decide({ builderReady: false })).toBe("builder-not-ready");
|
|
255
|
+
expect(decide({ backendReady: false })).toBe("backend-not-ready");
|
|
256
|
+
expect(decide({ restartPending: true })).toBe("restart-pending");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// A wake would boot straight back into the same compile error, and the developer is mid-fix anyway.
|
|
260
|
+
test("never suspends on a red build", () => {
|
|
261
|
+
expect(decide({ buildFailed: true })).toBe("build-failed");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("enforces a minimum uptime after a wake so it cannot flap", () => {
|
|
265
|
+
expect(decide({ msSinceWake: 5_000 })).toBe("too-soon");
|
|
266
|
+
expect(decide({ msSinceWake: 31_000 })).toBe("suspend");
|
|
267
|
+
expect(decide({ msSinceWake: 5_000, minUptimeMs: 1_000 })).toBe("suspend");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("defaults to on and treats any non-positive value as off", () => {
|
|
271
|
+
expect(resolveIdleSuspendMs(undefined)).toBe(300_000);
|
|
272
|
+
expect(resolveIdleSuspendMs("")).toBe(300_000);
|
|
273
|
+
expect(resolveIdleSuspendMs("0")).toBeNull();
|
|
274
|
+
expect(resolveIdleSuspendMs("-1")).toBeNull();
|
|
275
|
+
expect(resolveIdleSuspendMs("not-a-number")).toBeNull();
|
|
276
|
+
expect(resolveIdleSuspendMs("1500")).toBe(1_500);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("blocks a suspend on a failure in any phase, not just the newest generation", () => {
|
|
280
|
+
const status = (phase: BuildPhase, ok: boolean): DevBuildStatus => ({ generation: 1, phase, ok, files: [] });
|
|
281
|
+
expect(hasAnyBuildFailure(new Map())).toBe(false);
|
|
282
|
+
expect(hasAnyBuildFailure(new Map([["scan", status("scan", true)]]))).toBe(false);
|
|
283
|
+
expect(
|
|
284
|
+
hasAnyBuildFailure(
|
|
285
|
+
new Map([
|
|
286
|
+
["scan", status("scan", true)],
|
|
287
|
+
["pages", status("pages", false)],
|
|
288
|
+
]),
|
|
289
|
+
),
|
|
290
|
+
).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("routes a config change made while suspended through the dev host restart", () => {
|
|
294
|
+
expect(shouldRefreshConfigOnIdleWake(null)).toBe(false);
|
|
295
|
+
expect(shouldRefreshConfigOnIdleWake({ files: ["/repo/apps/demo/ui/A.tsx"], kinds: new Set(["code"]) })).toBe(
|
|
296
|
+
false,
|
|
297
|
+
);
|
|
298
|
+
expect(
|
|
299
|
+
shouldRefreshConfigOnIdleWake({
|
|
300
|
+
files: ["/repo/apps/demo/akan.config.ts"],
|
|
301
|
+
kinds: new Set(["config", "code"]),
|
|
302
|
+
}),
|
|
303
|
+
).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("recycled builder state announcements", () => {
|
|
308
|
+
const pages = (bundlePath: string): Extract<BuilderMessage, { type: "pages-updated" }> => ({
|
|
309
|
+
type: "pages-updated",
|
|
310
|
+
data: { bundlePath, buildId: 7, generation: 3, changedFiles: [], reason: "builder-recycle" },
|
|
311
|
+
});
|
|
312
|
+
const css = (cssUrl: string): Extract<BuilderMessage, { type: "css-updated" }> => ({
|
|
313
|
+
type: "css-updated",
|
|
314
|
+
data: {
|
|
315
|
+
cssAssets: { "": { cssUrl, cssRelPath: cssUrl.slice(1) } },
|
|
316
|
+
cssBase64ByUrl: { [cssUrl]: "" },
|
|
317
|
+
generation: 3,
|
|
318
|
+
changedFiles: [],
|
|
319
|
+
reason: "builder-recycle",
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Both identities are content hashes, so a recycle with no concurrent edit reproduces them exactly
|
|
324
|
+
// and must not reload the backend — that would refresh every browser on a memory recycle.
|
|
325
|
+
test("suppresses an unchanged pages announcement and relays a moved one", () => {
|
|
326
|
+
expect(shouldRelayRecycledFrontendState(pages("/a/pages-abc.js"), pages("/a/pages-abc.js"))).toBe(false);
|
|
327
|
+
expect(shouldRelayRecycledFrontendState(pages("/a/pages-abc.js"), pages("/a/pages-def.js"))).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("suppresses an unchanged css announcement and relays a moved one", () => {
|
|
331
|
+
expect(shouldRelayRecycledFrontendState(css("/_akan/styles/root-abc.css"), css("/_akan/styles/root-abc.css"))).toBe(
|
|
332
|
+
false,
|
|
333
|
+
);
|
|
334
|
+
expect(shouldRelayRecycledFrontendState(css("/_akan/styles/root-abc.css"), css("/_akan/styles/root-def.css"))).toBe(
|
|
335
|
+
true,
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("relays when the backend has no state of that kind yet", () => {
|
|
340
|
+
expect(shouldRelayRecycledFrontendState(undefined, pages("/a/pages-abc.js"))).toBe(true);
|
|
341
|
+
expect(shouldRelayRecycledFrontendState(css("/_akan/styles/root-abc.css"), pages("/a/pages-abc.js"))).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
144
345
|
describe("build status helpers", () => {
|
|
145
346
|
const status = (phase: DevBuildStatus["phase"], generation: number, ok: boolean): DevBuildStatus => ({
|
|
146
347
|
generation,
|
|
@@ -199,6 +400,86 @@ describe("build status helpers", () => {
|
|
|
199
400
|
});
|
|
200
401
|
});
|
|
201
402
|
|
|
403
|
+
describe("BackendImportGraph", () => {
|
|
404
|
+
const tempRoots: string[] = [];
|
|
405
|
+
|
|
406
|
+
const makeGraph = async (files: Record<string, string>) => {
|
|
407
|
+
// Realpath, not the mkdtemp path: `Bun.resolveSync` returns real paths, and on macOS `/var/folders`
|
|
408
|
+
// is a symlink, so an unresolved root makes every resolved import look like it escapes the workspace.
|
|
409
|
+
const workspaceRoot = await realpath(await mkdtemp(path.join(os.tmpdir(), "akan-devkit-graph-")));
|
|
410
|
+
tempRoots.push(workspaceRoot);
|
|
411
|
+
const cwdPath = path.join(workspaceRoot, "apps/demo");
|
|
412
|
+
for (const [rel, source] of Object.entries(files)) {
|
|
413
|
+
const filePath = path.join(cwdPath, rel);
|
|
414
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
415
|
+
await writeFile(filePath, source);
|
|
416
|
+
}
|
|
417
|
+
const app = { cwdPath, workspace: { workspaceRoot } } as unknown as App;
|
|
418
|
+
return { graph: new BackendImportGraph(app, new Logger("test")), cwdPath };
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
afterEach(async () => {
|
|
422
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("walks the backend entrypoints' import graph", async () => {
|
|
426
|
+
const { graph, cwdPath } = await makeGraph({
|
|
427
|
+
"main.ts": 'import "./server";\n',
|
|
428
|
+
"server.ts": 'import { handler } from "./lib/handler";\nexport default handler;\n',
|
|
429
|
+
"lib/handler.ts": "export const handler = () => null;\n",
|
|
430
|
+
"lib/unreachable.ts": "export const nope = 1;\n",
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
expect(await graph.refresh()).toBe(true);
|
|
434
|
+
expect(graph.has(path.join(cwdPath, "lib/handler.ts"))).toBe(true);
|
|
435
|
+
expect(graph.has(path.join(cwdPath, "lib/unreachable.ts"))).toBe(false);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("picks up an import added to an already-scanned file", async () => {
|
|
439
|
+
const { graph, cwdPath } = await makeGraph({
|
|
440
|
+
"main.ts": 'import "./server";\n',
|
|
441
|
+
"server.ts": "export default 1;\n",
|
|
442
|
+
"lib/added.ts": "export const added = 1;\n",
|
|
443
|
+
});
|
|
444
|
+
await graph.refresh();
|
|
445
|
+
expect(graph.has(path.join(cwdPath, "lib/added.ts"))).toBe(false);
|
|
446
|
+
|
|
447
|
+
// The scan cache is keyed on (mtimeMs, size), so the rewrite must invalidate it.
|
|
448
|
+
await writeFile(path.join(cwdPath, "server.ts"), 'import "./lib/added";\nexport default 1;\n');
|
|
449
|
+
|
|
450
|
+
await graph.refresh();
|
|
451
|
+
expect(graph.has(path.join(cwdPath, "lib/added.ts"))).toBe(true);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("drops a file that left the graph", async () => {
|
|
455
|
+
const { graph, cwdPath } = await makeGraph({
|
|
456
|
+
"main.ts": 'import "./server";\n',
|
|
457
|
+
"server.ts": 'import "./lib/leaving";\nexport default 1;\n',
|
|
458
|
+
"lib/leaving.ts": "export const leaving = 1;\n",
|
|
459
|
+
});
|
|
460
|
+
await graph.refresh();
|
|
461
|
+
expect(graph.has(path.join(cwdPath, "lib/leaving.ts"))).toBe(true);
|
|
462
|
+
|
|
463
|
+
await writeFile(path.join(cwdPath, "server.ts"), "export default 1;\n");
|
|
464
|
+
await graph.refresh();
|
|
465
|
+
expect(graph.has(path.join(cwdPath, "lib/leaving.ts"))).toBe(false);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("keeps the previous graph when a refresh finds no entrypoints", async () => {
|
|
469
|
+
const { graph, cwdPath } = await makeGraph({
|
|
470
|
+
"main.ts": 'import "./lib/kept";\n',
|
|
471
|
+
"lib/kept.ts": "export const kept = 1;\n",
|
|
472
|
+
});
|
|
473
|
+
await graph.refresh();
|
|
474
|
+
expect(graph.ready).toBe(true);
|
|
475
|
+
|
|
476
|
+
await rm(path.join(cwdPath, "main.ts"));
|
|
477
|
+
await graph.refresh();
|
|
478
|
+
// An empty scan is not a failure, so the graph legitimately empties out.
|
|
479
|
+
expect(graph.has(path.join(cwdPath, "lib/kept.ts"))).toBe(false);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
202
483
|
describe("legacy backend graph fallback", () => {
|
|
203
484
|
const root = "/repo";
|
|
204
485
|
|