@akanjs/devkit 3.0.0-alpha.71 → 3.0.0-alpha.72

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.
Files changed (117) hide show
  1. package/akanApp/BackendImportGraph.test.ts +120 -0
  2. package/akanApp/BackendImportGraph.ts +167 -0
  3. package/akanApp/akanApp.host.test.ts +16 -678
  4. package/akanApp/akanApp.host.ts +65 -562
  5. package/akanApp/devHostPolicy.test.ts +542 -0
  6. package/akanApp/devHostPolicy.ts +412 -0
  7. package/akanApp/index.ts +2 -0
  8. package/akanContext.ts +17 -3
  9. package/akanMcpContract.ts +25 -4
  10. package/artifact/routeSeedIndex.test.ts +1 -0
  11. package/capacitorApp.ts +10 -0
  12. package/cloud/globalConfig.ts +9 -2
  13. package/commandDecorators/command.ts +30 -6
  14. package/commandDecorators/commandBuilder.ts +20 -5
  15. package/commandDecorators/commandDecorators.test.ts +115 -3
  16. package/dependencyScanner.test.ts +99 -0
  17. package/dependencyScanner.ts +27 -24
  18. package/devkitUtils.test.ts +0 -104
  19. package/executors.test.ts +45 -0
  20. package/executors.ts +29 -5
  21. package/fileEditor.ts +19 -19
  22. package/frontendBuild/autoImportSync.test.ts +58 -0
  23. package/frontendBuild/autoImportSync.ts +4 -0
  24. package/frontendBuild/buildRouteClient.test.ts +19 -22
  25. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  26. package/frontendBuild/fontOptimizer.ts +37 -18
  27. package/frontendBuild/index.ts +0 -1
  28. package/frontendBuild/styleContract.ts +14 -20
  29. package/frontendBuild/themeValidator.ts +22 -17
  30. package/incrementalBuilder/devWatchBatch.ts +6 -2
  31. package/incrementalBuilder/incrementalBuilder.proc.ts +6 -2
  32. package/index.ts +0 -5
  33. package/lint/__fixtures__/README.md +40 -0
  34. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  35. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  36. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  37. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  38. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  39. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  40. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  41. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  42. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  43. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  44. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  45. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  46. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  47. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  48. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  49. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  50. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  51. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  52. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  53. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  54. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  55. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  56. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  57. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  58. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  59. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  60. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  61. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  62. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  63. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  64. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  65. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  66. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  67. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  68. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  69. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  70. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  71. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  72. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  73. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  74. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  75. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  76. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  77. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  78. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  79. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  80. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  81. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  82. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  83. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  84. package/lint/gritRules.test.ts +178 -0
  85. package/lint/no-arbitrary-color.grit +1 -1
  86. package/lint/no-bang-comment-in-client.grit +23 -10
  87. package/lint/no-daisyui-legacy-class.grit +2 -2
  88. package/lint/no-inline-color.grit +10 -8
  89. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  90. package/lint/no-raw-palette-class.grit +3 -3
  91. package/lint/non-scalar-props-restricted.grit +16 -7
  92. package/linter.test.ts +80 -0
  93. package/linter.ts +82 -13
  94. package/package.json +3 -3
  95. package/prompter.ts +9 -4
  96. package/qualityScanner.test.ts +64 -0
  97. package/qualityScanner.ts +43 -3
  98. package/recipeScanner.ts +4 -1
  99. package/scanInfo.ts +3 -3
  100. package/ssrScanner.test.ts +301 -0
  101. package/transforms/barrelImportsPlugin.ts +1 -1
  102. package/transforms/transforms.test.ts +5 -5
  103. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  104. package/tsconfig.json +5 -1
  105. package/typeChecker.ts +1 -1
  106. package/types.ts +1 -0
  107. package/ui/ScrollList.tsx +6 -8
  108. package/uploadRelease.ts +2 -2
  109. package/workflow/executor.test.ts +146 -0
  110. package/builder.ts +0 -164
  111. package/extractDeps.ts +0 -86
  112. package/frontendBuild/styleGuard.test.ts +0 -180
  113. package/frontendBuild/styleGuard.ts +0 -339
  114. package/getCredentials.ts +0 -17
  115. package/getModelFileData.ts +0 -62
  116. package/src/capacitorApp.ts +0 -282
  117. package/streamAi.ts +0 -45
@@ -0,0 +1,542 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangeAction } from "akanjs/server";
3
+ import {
4
+ backendRestartReasonFromMessage,
5
+ buildStatusReplaySequence,
6
+ createBackendBuildStatus,
7
+ decideBuilderRssRecycle,
8
+ decideBuilderRssSettle,
9
+ decideIdleSuspend,
10
+ hasAnyBuildFailure,
11
+ hasBuildFailureForGeneration,
12
+ isLegacyBackendFallbackFile,
13
+ isRssCeilingUnreachable,
14
+ mergeBackendRestartReasons,
15
+ mergeInvalidateMessages,
16
+ normalizeBackendReportedGeneration,
17
+ resolveIdleSuspendMs,
18
+ shouldAbandonBackendRecovery,
19
+ shouldHoldForReturningBuilder,
20
+ shouldMarkBuildPhaseRecovered,
21
+ shouldQueueBuildStatusReplay,
22
+ shouldRefreshConfigOnIdleWake,
23
+ shouldRelayRecycledFrontendState,
24
+ shouldReplaceLastGoodMessage,
25
+ shouldRestartBackendByDevPlan,
26
+ shouldRestartBuilderByDevPlan,
27
+ shouldRestartDevHostByDevPlan,
28
+ shouldWarnBuilderRssCeilingTight,
29
+ } from "./devHostPolicy";
30
+
31
+ const invalidateWithActions = (actions: DevChangeAction[]): Extract<BuilderMessage, { type: "invalidate" }> => ({
32
+ type: "invalidate",
33
+ kinds: ["code"],
34
+ files: ["/repo/libs/shared/common/foo.ts"],
35
+ generation: 1,
36
+ devPlan: {
37
+ generation: 1,
38
+ files: ["/repo/libs/shared/common/foo.ts"],
39
+ generatedFiles: [],
40
+ roles: ["shared"],
41
+ actions,
42
+ reasonByFile: {},
43
+ },
44
+ });
45
+
46
+ describe("shouldRestartBackendByDevPlan", () => {
47
+ test("uses explicit restart-backend action", () => {
48
+ expect(shouldRestartBackendByDevPlan(invalidateWithActions(["restart-backend"]))).toBe(true);
49
+ });
50
+
51
+ test("blocks restart for error generations", () => {
52
+ expect(shouldRestartBackendByDevPlan(invalidateWithActions(["restart-backend", "report-error"]))).toBe(false);
53
+ });
54
+
55
+ test("does not use backend-only restart when builder recycle is required", () => {
56
+ expect(shouldRestartBackendByDevPlan(invalidateWithActions(["restart-backend", "restart-builder"]))).toBe(false);
57
+ });
58
+
59
+ test("falls back when no devPlan is present", () => {
60
+ expect(
61
+ shouldRestartBackendByDevPlan({
62
+ type: "invalidate",
63
+ kinds: ["code"],
64
+ files: ["/repo/apps/akan/page/_index.tsx"],
65
+ }),
66
+ ).toBeNull();
67
+ });
68
+ });
69
+
70
+ describe("shouldRestartDevHostByDevPlan", () => {
71
+ test("detects explicit restart-builder actions", () => {
72
+ expect(shouldRestartBuilderByDevPlan(invalidateWithActions(["restart-builder"]))).toBe(true);
73
+ });
74
+
75
+ test("detects explicit restart-dev-host actions", () => {
76
+ expect(shouldRestartDevHostByDevPlan(invalidateWithActions(["restart-dev-host"]))).toBe(true);
77
+ });
78
+
79
+ test("detects legacy config invalidates", () => {
80
+ expect(
81
+ shouldRestartDevHostByDevPlan({
82
+ type: "invalidate",
83
+ kinds: ["config"],
84
+ files: ["/repo/apps/akan/akan.config.ts"],
85
+ }),
86
+ ).toBe(true);
87
+ });
88
+ });
89
+
90
+ describe("backend restart reason helpers", () => {
91
+ test("extracts restart roles and generation from devPlan", () => {
92
+ const message = invalidateWithActions(["restart-backend"]);
93
+ const devPlan = message.devPlan;
94
+ if (!devPlan) throw new Error("devPlan expected");
95
+ message.devPlan = {
96
+ ...devPlan,
97
+ roles: ["client", "shared", "barrel", "css"],
98
+ generation: 7,
99
+ };
100
+
101
+ expect(backendRestartReasonFromMessage(message)).toEqual({
102
+ generation: 7,
103
+ files: ["/repo/libs/shared/common/foo.ts"],
104
+ roles: ["shared", "barrel"],
105
+ });
106
+ });
107
+
108
+ test("merges debounced restart reasons without losing latest generation", () => {
109
+ expect(
110
+ mergeBackendRestartReasons(
111
+ { generation: 12, files: ["/repo/b.ts", "/repo/a.ts"], roles: ["server"] },
112
+ { generation: 13, files: ["/repo/b.ts", "/repo/c.ts"], roles: ["barrel", "shared"] },
113
+ ),
114
+ ).toEqual({
115
+ generation: 13,
116
+ files: ["/repo/a.ts", "/repo/b.ts", "/repo/c.ts"],
117
+ roles: ["server", "shared", "barrel"],
118
+ });
119
+ });
120
+
121
+ test("keeps newer pending generation when an older reason is merged later", () => {
122
+ expect(
123
+ mergeBackendRestartReasons(
124
+ { generation: 14, files: ["/repo/newer.ts"], roles: ["shared"] },
125
+ { generation: 13, files: ["/repo/older.ts"], roles: ["server"] },
126
+ ).generation,
127
+ ).toBe(14);
128
+ });
129
+ });
130
+
131
+ describe("last-good frontend helpers", () => {
132
+ const pagesUpdated = (
133
+ generation: number | undefined,
134
+ buildId: number,
135
+ ): Extract<BuilderMessage, { type: "pages-updated" }> => ({
136
+ type: "pages-updated",
137
+ data: {
138
+ bundlePath: `/tmp/pages-${buildId}.js`,
139
+ buildId,
140
+ generation,
141
+ changedFiles: [],
142
+ },
143
+ });
144
+
145
+ test("accepts newer successful pages payloads", () => {
146
+ expect(shouldReplaceLastGoodMessage(pagesUpdated(10, 1), pagesUpdated(11, 2))).toBe(true);
147
+ });
148
+
149
+ test("rejects stale successful pages payloads", () => {
150
+ expect(shouldReplaceLastGoodMessage(pagesUpdated(11, 2), pagesUpdated(10, 1))).toBe(false);
151
+ });
152
+ });
153
+
154
+ describe("holding requests for a returning builder", () => {
155
+ test("holds while the builder is on its way back", () => {
156
+ expect(shouldHoldForReturningBuilder({ status: "restarting", heldCount: 0 })).toBe(true);
157
+ expect(shouldHoldForReturningBuilder({ status: "starting", heldCount: 3 })).toBe(true);
158
+ });
159
+
160
+ test("holds through the drain too, not only after the process is gone", () => {
161
+ // The window this decision originally missed: the builder is still alive and refusing, which is
162
+ // the same gap as a restart from anyone waiting on a page.
163
+ expect(shouldHoldForReturningBuilder({ status: "recycling", heldCount: 0 })).toBe(true);
164
+ });
165
+
166
+ test("fails immediately when nothing is bringing the builder back", () => {
167
+ expect(shouldHoldForReturningBuilder({ status: "stopped", heldCount: 0 })).toBe(false);
168
+ });
169
+
170
+ test("stops holding once the queue is full", () => {
171
+ expect(shouldHoldForReturningBuilder({ status: "restarting", heldCount: 3, limit: 4 })).toBe(true);
172
+ expect(shouldHoldForReturningBuilder({ status: "restarting", heldCount: 4, limit: 4 })).toBe(false);
173
+ });
174
+ });
175
+
176
+ describe("builder rss recycle", () => {
177
+ const ceiling = 1_200 * 1024 * 1024;
178
+ const decide = (over: Partial<Parameters<typeof decideBuilderRssRecycle>[0]>) =>
179
+ decideBuilderRssRecycle({
180
+ rssBytes: ceiling + 1,
181
+ ceilingBytes: ceiling,
182
+ buildFailed: false,
183
+ msSinceLastRecycle: null,
184
+ ...over,
185
+ });
186
+
187
+ test("recycles an idle builder that crossed the ceiling", () => {
188
+ expect(decide({})).toBe("recycle");
189
+ });
190
+
191
+ test("leaves a builder under the ceiling alone", () => {
192
+ expect(decide({ rssBytes: ceiling - 1 })).toBe("below-ceiling");
193
+ });
194
+
195
+ test("does nothing when no ceiling is configured", () => {
196
+ expect(decide({ ceilingBytes: null })).toBe("unbounded");
197
+ });
198
+
199
+ // Rebooting on a generation whose build failed strands the dev server: the replacement hits the
200
+ // same compile error and exits before builder-ready.
201
+ test("defers while the current generation has a failing build", () => {
202
+ expect(decide({ buildFailed: true })).toBe("build-failed");
203
+ });
204
+
205
+ test("refuses a second recycle inside the minimum interval", () => {
206
+ expect(decide({ msSinceLastRecycle: 5_000 })).toBe("too-soon");
207
+ expect(decide({ msSinceLastRecycle: 31_000 })).toBe("recycle");
208
+ expect(decide({ msSinceLastRecycle: 5_000, minIntervalMs: 1_000 })).toBe("recycle");
209
+ });
210
+
211
+ // Says so, and keeps enforcing: a page load is two route builds, so an app whose builds sit over the
212
+ // ceiling reaches this on its first navigation — which is normal work, not a reason to drop the only
213
+ // bound the builder has.
214
+ test("mentions a tight ceiling rather than acting on it", () => {
215
+ expect(shouldWarnBuilderRssCeilingTight(1)).toBe(false);
216
+ expect(shouldWarnBuilderRssCeilingTight(2)).toBe(true);
217
+ expect(shouldWarnBuilderRssCeilingTight(1, 1)).toBe(true);
218
+ });
219
+
220
+ // The one case recycling cannot fix, measured on the replacement before it has built anything: every
221
+ // future replacement lands on the same floor, so the loop would only ever cost boot builds.
222
+ test("gives up only when a fresh builder is already over the ceiling", () => {
223
+ expect(isRssCeilingUnreachable(ceiling + 1, ceiling)).toBe(true);
224
+ expect(isRssCeilingUnreachable(ceiling - 1, ceiling)).toBe(false);
225
+ // An unreadable rss is no information, and no ceiling is nothing to be unreachable.
226
+ expect(isRssCeilingUnreachable(null, ceiling)).toBe(false);
227
+ expect(isRssCeilingUnreachable(ceiling + 1, null)).toBe(false);
228
+ });
229
+
230
+ // Measured on Linux: the builder peaked at 522MiB and settled at 214MiB with no help, so a 400MiB
231
+ // ceiling recycled a process that was already back under it. The armed sample is always the peak.
232
+ describe("settle check before committing", () => {
233
+ test("waits when the builder is only modestly over the ceiling", () => {
234
+ expect(decideBuilderRssSettle({ rssBytes: 522, ceilingBytes: 400 })).toBe("wait-and-recheck");
235
+ expect(decideBuilderRssSettle({ rssBytes: 401, ceilingBytes: 400 })).toBe("wait-and-recheck");
236
+ });
237
+
238
+ // No purge is going to rescue a builder this far over, so waiting only delays the inevitable.
239
+ test("recycles immediately once far enough past the ceiling", () => {
240
+ expect(decideBuilderRssSettle({ rssBytes: 600, ceilingBytes: 400 })).toBe("recycle-now");
241
+ expect(decideBuilderRssSettle({ rssBytes: 900, ceilingBytes: 400 })).toBe("recycle-now");
242
+ expect(decideBuilderRssSettle({ rssBytes: 500, ceilingBytes: 400, hardMultiple: 1.2 })).toBe("recycle-now");
243
+ });
244
+ });
245
+ });
246
+ describe("dev idle suspend", () => {
247
+ const decide = (over: Partial<Parameters<typeof decideIdleSuspend>[0]> = {}) =>
248
+ decideIdleSuspend({
249
+ enabled: true,
250
+ suspended: false,
251
+ builderReady: true,
252
+ backendReady: true,
253
+ buildFailed: false,
254
+ restartPending: false,
255
+ msSinceWake: null,
256
+ ...over,
257
+ });
258
+
259
+ test("suspends an idle dev server whose builder and backend are both up", () => {
260
+ expect(decide()).toBe("suspend");
261
+ });
262
+
263
+ test("keeps build capacity while anything is still in motion", () => {
264
+ expect(decide({ enabled: false })).toBe("disabled");
265
+ expect(decide({ suspended: true })).toBe("already-suspended");
266
+ expect(decide({ builderReady: false })).toBe("builder-not-ready");
267
+ expect(decide({ backendReady: false })).toBe("backend-not-ready");
268
+ expect(decide({ restartPending: true })).toBe("restart-pending");
269
+ });
270
+
271
+ // A wake would boot straight back into the same compile error, and the developer is mid-fix anyway.
272
+ test("never suspends on a red build", () => {
273
+ expect(decide({ buildFailed: true })).toBe("build-failed");
274
+ });
275
+
276
+ test("enforces a minimum uptime after a wake so it cannot flap", () => {
277
+ expect(decide({ msSinceWake: 5_000 })).toBe("too-soon");
278
+ expect(decide({ msSinceWake: 31_000 })).toBe("suspend");
279
+ expect(decide({ msSinceWake: 5_000, minUptimeMs: 1_000 })).toBe("suspend");
280
+ });
281
+
282
+ test("defaults to on and treats any non-positive value as off", () => {
283
+ expect(resolveIdleSuspendMs(undefined)).toBe(300_000);
284
+ expect(resolveIdleSuspendMs("")).toBe(300_000);
285
+ expect(resolveIdleSuspendMs("0")).toBeNull();
286
+ expect(resolveIdleSuspendMs("-1")).toBeNull();
287
+ expect(resolveIdleSuspendMs("not-a-number")).toBeNull();
288
+ expect(resolveIdleSuspendMs("1500")).toBe(1_500);
289
+ });
290
+
291
+ test("blocks a suspend on a failure in any phase, not just the newest generation", () => {
292
+ const status = (phase: BuildPhase, ok: boolean): DevBuildStatus => ({ generation: 1, phase, ok, files: [] });
293
+ expect(hasAnyBuildFailure(new Map())).toBe(false);
294
+ expect(hasAnyBuildFailure(new Map([["scan", status("scan", true)]]))).toBe(false);
295
+ expect(
296
+ hasAnyBuildFailure(
297
+ new Map([
298
+ ["scan", status("scan", true)],
299
+ ["pages", status("pages", false)],
300
+ ]),
301
+ ),
302
+ ).toBe(true);
303
+ });
304
+
305
+ test("routes a config change made while suspended through the dev host restart", () => {
306
+ expect(shouldRefreshConfigOnIdleWake(null)).toBe(false);
307
+ expect(shouldRefreshConfigOnIdleWake({ files: ["/repo/apps/demo/ui/A.tsx"], kinds: new Set(["code"]) })).toBe(
308
+ false,
309
+ );
310
+ expect(
311
+ shouldRefreshConfigOnIdleWake({
312
+ files: ["/repo/apps/demo/akan.config.ts"],
313
+ kinds: new Set(["config", "code"]),
314
+ }),
315
+ ).toBe(true);
316
+ });
317
+ });
318
+
319
+ describe("recycled builder state announcements", () => {
320
+ const pages = (bundlePath: string): Extract<BuilderMessage, { type: "pages-updated" }> => ({
321
+ type: "pages-updated",
322
+ data: { bundlePath, buildId: 7, generation: 3, changedFiles: [], reason: "builder-recycle" },
323
+ });
324
+ const css = (cssUrl: string): Extract<BuilderMessage, { type: "css-updated" }> => ({
325
+ type: "css-updated",
326
+ data: {
327
+ cssAssets: { "": { cssUrl, cssRelPath: cssUrl.slice(1) } },
328
+ cssBase64ByUrl: { [cssUrl]: "" },
329
+ generation: 3,
330
+ changedFiles: [],
331
+ reason: "builder-recycle",
332
+ },
333
+ });
334
+
335
+ // Both identities are content hashes, so a recycle with no concurrent edit reproduces them exactly
336
+ // and must not reload the backend — that would refresh every browser on a memory recycle.
337
+ test("suppresses an unchanged pages announcement and relays a moved one", () => {
338
+ expect(shouldRelayRecycledFrontendState(pages("/a/pages-abc.js"), pages("/a/pages-abc.js"))).toBe(false);
339
+ expect(shouldRelayRecycledFrontendState(pages("/a/pages-abc.js"), pages("/a/pages-def.js"))).toBe(true);
340
+ });
341
+
342
+ test("suppresses an unchanged css announcement and relays a moved one", () => {
343
+ expect(shouldRelayRecycledFrontendState(css("/_akan/styles/root-abc.css"), css("/_akan/styles/root-abc.css"))).toBe(
344
+ false,
345
+ );
346
+ expect(shouldRelayRecycledFrontendState(css("/_akan/styles/root-abc.css"), css("/_akan/styles/root-def.css"))).toBe(
347
+ true,
348
+ );
349
+ });
350
+
351
+ test("relays when the backend has no state of that kind yet", () => {
352
+ expect(shouldRelayRecycledFrontendState(undefined, pages("/a/pages-abc.js"))).toBe(true);
353
+ expect(shouldRelayRecycledFrontendState(css("/_akan/styles/root-abc.css"), pages("/a/pages-abc.js"))).toBe(true);
354
+ });
355
+ });
356
+
357
+ describe("build status helpers", () => {
358
+ const status = (phase: DevBuildStatus["phase"], generation: number, ok: boolean): DevBuildStatus => ({
359
+ generation,
360
+ phase,
361
+ ok,
362
+ files: [],
363
+ });
364
+
365
+ test("tracks recovery by phase without unrelated phases masking failures", () => {
366
+ const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
367
+ ["pages", status("pages", 10, false)],
368
+ ["css", status("css", 11, true)],
369
+ ]);
370
+
371
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("css", 12, true))).toBe(false);
372
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("pages", 12, true))).toBe(true);
373
+ });
374
+
375
+ test("creates backend build-status payloads for lifecycle test hooks", () => {
376
+ expect(
377
+ createBackendBuildStatus({
378
+ generation: 14,
379
+ ok: false,
380
+ files: ["/repo/libs/shared/common/foo.ts"],
381
+ message: "Backend exited unexpectedly",
382
+ }),
383
+ ).toEqual({
384
+ generation: 14,
385
+ phase: "backend",
386
+ ok: false,
387
+ files: ["/repo/libs/shared/common/foo.ts"],
388
+ message: "Backend exited unexpectedly",
389
+ });
390
+ });
391
+
392
+ test("marks backend phase recovered independently", () => {
393
+ const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
394
+ ["backend", createBackendBuildStatus({ generation: 15, ok: false, message: "Backend exited" })],
395
+ ["pages", status("pages", 16, false)],
396
+ ]);
397
+
398
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, createBackendBuildStatus({ generation: 15, ok: true }))).toBe(
399
+ true,
400
+ );
401
+ });
402
+
403
+ test("keeps backend failure and recovery statuses ordered for backend replay", () => {
404
+ const failed = createBackendBuildStatus({ generation: 15, ok: false, message: "Backend exited" });
405
+ const recovered = createBackendBuildStatus({ generation: 15, ok: true, message: "Backend ready" });
406
+ const latestByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["backend", recovered]]);
407
+
408
+ expect(shouldQueueBuildStatusReplay(false, 0)).toBe(true);
409
+ expect(shouldQueueBuildStatusReplay(true, 1)).toBe(true);
410
+ expect(shouldQueueBuildStatusReplay(true, 0)).toBe(false);
411
+ expect(buildStatusReplaySequence([failed, recovered], latestByPhase).slice(0, 2)).toEqual([failed, recovered]);
412
+ });
413
+ });
414
+ describe("legacy backend graph fallback", () => {
415
+ const root = "/repo";
416
+
417
+ test("treats server and shared path roles as backend restart candidates", () => {
418
+ expect(isLegacyBackendFallbackFile(`${root}/libs/shared/srvkit/foo.ts`, root)).toBe(true);
419
+ expect(isLegacyBackendFallbackFile(`${root}/libs/shared/common/foo.ts`, root)).toBe(true);
420
+ expect(isLegacyBackendFallbackFile(`${root}/libs/shared/lib/admin/admin.signal.ts`, root)).toBe(true);
421
+ });
422
+
423
+ test("does not restart backend for client-only path roles", () => {
424
+ expect(isLegacyBackendFallbackFile(`${root}/libs/shared/ui/Foo.tsx`, root)).toBe(false);
425
+ expect(isLegacyBackendFallbackFile(`${root}/apps/akan/page/_index.tsx`, root)).toBe(false);
426
+ });
427
+ });
428
+
429
+ describe("backend recovery abandonment", () => {
430
+ test("keeps retrying below the attempt ceiling and abandons at it", () => {
431
+ expect(shouldAbandonBackendRecovery(0)).toBe(false);
432
+ expect(shouldAbandonBackendRecovery(4)).toBe(false);
433
+ expect(shouldAbandonBackendRecovery(5)).toBe(true);
434
+ expect(shouldAbandonBackendRecovery(9)).toBe(true);
435
+ });
436
+
437
+ test("honors a custom attempt ceiling", () => {
438
+ expect(shouldAbandonBackendRecovery(2, 3)).toBe(false);
439
+ expect(shouldAbandonBackendRecovery(3, 3)).toBe(true);
440
+ });
441
+ });
442
+
443
+ describe("hasBuildFailureForGeneration", () => {
444
+ const status = (phase: DevBuildStatus["phase"], generation: number, ok: boolean): DevBuildStatus => ({
445
+ generation,
446
+ phase,
447
+ ok,
448
+ files: [],
449
+ });
450
+
451
+ test("detects a failing phase recorded for the same generation", () => {
452
+ const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
453
+ ["csr", status("csr", 3, false)],
454
+ ["barrel", status("barrel", 3, true)],
455
+ ]);
456
+ expect(hasBuildFailureForGeneration(statusByPhase, 3)).toBe(true);
457
+ });
458
+
459
+ test("ignores stale failures from earlier generations", () => {
460
+ const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
461
+ ["csr", status("csr", 3, false)],
462
+ ["barrel", status("barrel", 4, true)],
463
+ ]);
464
+ expect(hasBuildFailureForGeneration(statusByPhase, 4)).toBe(false);
465
+ });
466
+
467
+ test("treats unknown generations and green boards as healthy", () => {
468
+ const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["csr", status("csr", 3, false)]]);
469
+ expect(hasBuildFailureForGeneration(statusByPhase, undefined)).toBe(false);
470
+ expect(hasBuildFailureForGeneration(new Map(), 3)).toBe(false);
471
+ });
472
+ });
473
+
474
+ describe("mergeInvalidateMessages", () => {
475
+ const invalidate = (
476
+ generation: number,
477
+ files: string[],
478
+ actions: DevChangeAction[],
479
+ ): Extract<BuilderMessage, { type: "invalidate" }> => ({
480
+ type: "invalidate",
481
+ kinds: ["code"],
482
+ files,
483
+ generation,
484
+ devPlan: {
485
+ generation,
486
+ files,
487
+ generatedFiles: [],
488
+ roles: ["shared"],
489
+ actions,
490
+ reasonByFile: { [files[0] ?? "/repo/a.ts"]: ["shared-path"] },
491
+ },
492
+ });
493
+
494
+ test("unions files, kinds, and actions while keeping the latest generation", () => {
495
+ const merged = mergeInvalidateMessages(
496
+ invalidate(3, ["/repo/b.ts", "/repo/a.ts"], ["restart-builder"]),
497
+ invalidate(5, ["/repo/c.ts"], ["rebuild-client"]),
498
+ );
499
+
500
+ expect(merged.generation).toBe(5);
501
+ expect(merged.files).toEqual(["/repo/a.ts", "/repo/b.ts", "/repo/c.ts"]);
502
+ expect(merged.devPlan?.generation).toBe(5);
503
+ expect(merged.devPlan?.actions).toEqual(["rebuild-client", "restart-builder"]);
504
+ });
505
+
506
+ test("keeps the surviving devPlan when only one side carries one", () => {
507
+ const withPlan = invalidate(3, ["/repo/a.ts"], ["restart-builder"]);
508
+ const withoutPlan: Extract<BuilderMessage, { type: "invalidate" }> = {
509
+ type: "invalidate",
510
+ kinds: ["css"],
511
+ files: ["/repo/style.css"],
512
+ generation: 4,
513
+ };
514
+
515
+ const merged = mergeInvalidateMessages(withPlan, withoutPlan);
516
+ expect(merged.devPlan?.actions).toEqual(["restart-builder"]);
517
+ expect(merged.kinds).toEqual(["code", "css"]);
518
+ expect(merged.generation).toBe(4);
519
+ });
520
+
521
+ test("merges per-file reasons without duplicating entries", () => {
522
+ const current = invalidate(3, ["/repo/a.ts"], ["restart-builder"]);
523
+ const next = invalidate(4, ["/repo/a.ts"], ["restart-builder"]);
524
+ const nextPlan = next.devPlan;
525
+ if (!nextPlan) throw new Error("devPlan expected");
526
+ nextPlan.reasonByFile["/repo/a.ts"] = ["runtime-metadata", "shared-path"];
527
+
528
+ const merged = mergeInvalidateMessages(current, next);
529
+ expect(merged.devPlan?.reasonByFile["/repo/a.ts"]).toEqual(["runtime-metadata", "shared-path"]);
530
+ });
531
+ });
532
+
533
+ describe("normalizeBackendReportedGeneration", () => {
534
+ test("drops the gateway's unknown-generation sentinel", () => {
535
+ expect(normalizeBackendReportedGeneration(-1)).toBeUndefined();
536
+ });
537
+
538
+ test("keeps real generations including zero", () => {
539
+ expect(normalizeBackendReportedGeneration(0)).toBe(0);
540
+ expect(normalizeBackendReportedGeneration(7)).toBe(7);
541
+ });
542
+ });