@akanjs/devkit 3.0.0-beta.9 → 3.0.1

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 (346) hide show
  1. package/CHANGELOG.md +1819 -0
  2. package/DEV_RUNTIME_KNOBS.md +44 -26
  3. package/README.ko.md +21 -7
  4. package/README.md +17 -8
  5. package/abstractDoc.test.ts +6 -15
  6. package/abstractDoc.ts +6 -8
  7. package/agentsIndex.test.ts +4 -7
  8. package/agentsIndex.ts +6 -22
  9. package/akanApp/BackendImportGraph.test.ts +8 -22
  10. package/akanApp/BackendImportGraph.ts +1 -20
  11. package/akanApp/akanApp.host.test.ts +56 -3
  12. package/akanApp/akanApp.host.ts +218 -278
  13. package/akanApp/devBootLatch.test.ts +39 -0
  14. package/akanApp/devBootLatch.ts +39 -0
  15. package/akanApp/devHostPolicy.test.ts +81 -35
  16. package/akanApp/devHostPolicy.ts +41 -102
  17. package/akanConfig/akanBin.test.ts +62 -0
  18. package/akanConfig/akanBin.ts +78 -0
  19. package/akanConfig/akanConfig.test.ts +439 -141
  20. package/akanConfig/akanConfig.ts +359 -275
  21. package/akanConfig/index.ts +1 -0
  22. package/akanConfig/types.ts +22 -8
  23. package/akanContext.ts +110 -160
  24. package/akanMcpContract.ts +28 -73
  25. package/appSelectionMemory.ts +1 -4
  26. package/applicationBuildReporter.ts +37 -5
  27. package/applicationBuildRunner.test.ts +41 -5
  28. package/applicationBuildRunner.ts +71 -63
  29. package/applicationTestPreload.ts +3 -12
  30. package/artifact/implicitRootLayout.test.ts +34 -31
  31. package/artifact/implicitRootLayout.ts +60 -107
  32. package/artifact/routeSeedIndex.test.ts +5 -18
  33. package/artifact/routeSeedIndex.ts +11 -44
  34. package/biome.base.json +39 -2
  35. package/biomeBase.ts +2 -5
  36. package/cloud/cloudApi.test.ts +202 -0
  37. package/cloud/cloudApi.ts +77 -39
  38. package/cloud/configLock.test.ts +64 -0
  39. package/cloud/configLock.ts +96 -0
  40. package/cloud/constants.ts +26 -5
  41. package/cloud/globalConfig.ts +71 -32
  42. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  43. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  44. package/codeAgent/agent/CodeAgent.ts +551 -0
  45. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  46. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  47. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  48. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  49. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  50. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  51. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  52. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  53. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  54. package/codeAgent/agent/CodeMailbox.ts +148 -0
  55. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  56. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  57. package/codeAgent/agent/SubagentPool.ts +214 -0
  58. package/codeAgent/agent/akanCodeModel.ts +47 -0
  59. package/codeAgent/agent/akanCodePaths.ts +31 -0
  60. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  61. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  62. package/codeAgent/agent/codeAgent.test.ts +178 -0
  63. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  64. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  65. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  66. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  67. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  68. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  69. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  70. package/codeAgent/agent/consoleToStderr.ts +8 -0
  71. package/codeAgent/agent/jsonLines.ts +29 -0
  72. package/codeAgent/agent/stringArg.ts +5 -0
  73. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  74. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  75. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  76. package/codeAgent/feedback/PreviewView.ts +95 -0
  77. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  78. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  79. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  80. package/codeAgent/index.ts +34 -0
  81. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  82. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  83. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  84. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  85. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  86. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  87. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  88. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  89. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  90. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  91. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  92. package/codeAgent/tools/AkanCli.ts +50 -0
  93. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  94. package/codeAgent/tools/AkanEditScope.ts +48 -0
  95. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  96. package/codeAgent/tools/AkanToolPack.ts +92 -0
  97. package/codeAgent/tools/AkanVerifier.ts +48 -0
  98. package/codeAgent/tools/AskToolPack.ts +99 -0
  99. package/codeAgent/tools/MailToolPack.ts +76 -0
  100. package/codeAgent/tools/McpClient.ts +187 -0
  101. package/codeAgent/tools/McpOAuth.ts +233 -0
  102. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  103. package/codeAgent/tools/McpServerConfig.ts +129 -0
  104. package/codeAgent/tools/McpSignIn.ts +158 -0
  105. package/codeAgent/tools/McpTokenStore.ts +81 -0
  106. package/codeAgent/tools/McpToolPack.ts +199 -0
  107. package/codeAgent/tools/SessionToolPack.ts +91 -0
  108. package/codeAgent/tools/WebToolPack.ts +147 -0
  109. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  110. package/codeAgent/tools/askToolPack.test.ts +116 -0
  111. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  112. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  113. package/codegenLock.test.ts +4 -13
  114. package/codegenLock.ts +3 -17
  115. package/commandDecorators/argMeta.ts +1 -5
  116. package/commandDecorators/camelToKebabCase.ts +1 -0
  117. package/commandDecorators/command.ts +46 -102
  118. package/commandDecorators/commandBuilder.ts +30 -61
  119. package/commandDecorators/commandDecorators.test.ts +2 -10
  120. package/commandDecorators/dependencyBuilder.ts +10 -1
  121. package/commandDecorators/helpFormatter.ts +68 -145
  122. package/commandDecorators/index.ts +0 -2
  123. package/commandDecorators/targetMeta.ts +9 -7
  124. package/createTunnel.ts +7 -14
  125. package/csrE2e/csrE2eHarness.fixture.ts +506 -0
  126. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  127. package/csrE2e/csrPageStack.e2e.test.ts +265 -0
  128. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  129. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  130. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  131. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  132. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  133. package/dependencyScanner.test.ts +4 -16
  134. package/dependencyScanner.ts +31 -80
  135. package/devkitUtils.test.ts +48 -20
  136. package/executors.linkFallback.test.ts +103 -0
  137. package/executors.test.ts +176 -106
  138. package/executors.ts +212 -498
  139. package/fileEditor.ts +9 -37
  140. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  141. package/fileSys.ts +22 -9
  142. package/formSetterScanner.ts +3 -16
  143. package/frontendBuild/allRoutesBuilder.ts +7 -21
  144. package/frontendBuild/autoImportSync.test.ts +27 -38
  145. package/frontendBuild/autoImportSync.ts +31 -65
  146. package/frontendBuild/buildRouteClient.test.ts +123 -37
  147. package/frontendBuild/bundleDefine.test.ts +12 -0
  148. package/frontendBuild/bundleDefine.ts +16 -0
  149. package/frontendBuild/clientBuildTypes.ts +5 -36
  150. package/frontendBuild/clientEntriesBundler.ts +9 -40
  151. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  152. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  153. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  154. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  155. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  156. package/frontendBuild/csrDevBundler.test.ts +66 -0
  157. package/frontendBuild/csrDevBundler.ts +95 -0
  158. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  159. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  160. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  161. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  162. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  163. package/frontendBuild/csrDevPatcher.ts +548 -0
  164. package/frontendBuild/csrDevPaths.ts +145 -0
  165. package/frontendBuild/csrDevResolver.test.ts +239 -0
  166. package/frontendBuild/csrDevResolver.ts +363 -0
  167. package/frontendBuild/csrDevTypes.ts +61 -0
  168. package/frontendBuild/csrEntryFiles.ts +24 -0
  169. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  170. package/frontendBuild/cssCandidateCache.ts +6 -41
  171. package/frontendBuild/cssCompiler.ts +31 -82
  172. package/frontendBuild/cssImportResolver.ts +6 -19
  173. package/frontendBuild/devChangePlanner.ts +7 -10
  174. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  175. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  176. package/frontendBuild/devRegistryBundler.ts +192 -0
  177. package/frontendBuild/fontOptimizer.test.ts +6 -14
  178. package/frontendBuild/fontOptimizer.ts +15 -72
  179. package/frontendBuild/fontPruner.test.ts +5 -10
  180. package/frontendBuild/fontPruner.ts +5 -20
  181. package/frontendBuild/frontendBuild.test.ts +57 -70
  182. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  183. package/frontendBuild/hmrWatcher.test.ts +59 -32
  184. package/frontendBuild/hmrWatcher.ts +37 -74
  185. package/frontendBuild/index.ts +4 -0
  186. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  187. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  188. package/frontendBuild/precompressArtifacts.ts +5 -18
  189. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  190. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  191. package/frontendBuild/routeClientBuilder.ts +100 -62
  192. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  193. package/frontendBuild/serverGraphFile.test.ts +77 -0
  194. package/frontendBuild/serverGraphFile.ts +93 -0
  195. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  196. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  197. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  198. package/frontendBuild/ssrDevBundler.ts +85 -0
  199. package/frontendBuild/styleContract.ts +1 -8
  200. package/frontendBuild/themeValidator.ts +4 -26
  201. package/frontendBuild/watchRootResolver.test.ts +23 -42
  202. package/frontendBuild/watchRootResolver.ts +5 -27
  203. package/getRelatedCnsts.ts +12 -92
  204. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  205. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  206. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  207. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  208. package/incrementalBuilder/builderChannel.test.ts +24 -49
  209. package/incrementalBuilder/builderChannel.ts +6 -37
  210. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  211. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  212. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  213. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  214. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  215. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  216. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  217. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  218. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  219. package/index.ts +5 -19
  220. package/integration/devResourceProbe.ts +50 -56
  221. package/integration/devStability.integration.test.ts +111 -202
  222. package/integration/devStabilityHarness.test.ts +13 -28
  223. package/integration/devStabilityHarness.ts +67 -330
  224. package/integration/ssrMemoryProbe.ts +41 -167
  225. package/libSource.test.ts +4 -15
  226. package/libSource.ts +19 -31
  227. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  228. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  229. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +43 -0
  230. package/lint/__fixtures__/no-static-in-object-light-model/fixture.json +1 -0
  231. package/lint/__fixtures__/no-static-in-object-light-model/good.ts +42 -0
  232. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  233. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  234. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  235. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  236. package/lint/gritRules.test.ts +8 -24
  237. package/lint/no-deep-internal-import.grit +2 -2
  238. package/lint/no-document-cookie.grit +29 -0
  239. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  240. package/lint/no-static-in-object-light-model.grit +27 -0
  241. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  242. package/lint/no-web-storage.grit +30 -0
  243. package/linter.test.ts +1 -2
  244. package/linter.ts +26 -116
  245. package/mobile/desktopBin.test.ts +182 -0
  246. package/mobile/desktopBin.ts +144 -0
  247. package/mobile/desktopServerStage.test.ts +252 -0
  248. package/mobile/desktopServerStage.ts +178 -0
  249. package/mobile/index.ts +9 -0
  250. package/mobile/mobileTarget.test.ts +57 -8
  251. package/mobile/mobileTarget.ts +45 -26
  252. package/mobile/nativeApi.test.ts +30 -0
  253. package/mobile/nativeApi.ts +40 -0
  254. package/mobile/nativeApp.test.ts +184 -0
  255. package/mobile/nativeApp.ts +454 -0
  256. package/mobile/nativeAppLine.test.ts +69 -0
  257. package/mobile/nativeAppLine.ts +71 -0
  258. package/mobile/nativeConfig.test.ts +438 -0
  259. package/mobile/nativeConfig.ts +294 -0
  260. package/mobile/nativePluginFolders.test.ts +81 -0
  261. package/mobile/nativePluginFolders.ts +60 -0
  262. package/mobile/nativeWebDir.test.ts +80 -0
  263. package/mobile/nativeWebDir.ts +66 -0
  264. package/mobile/placeholderAppId.test.ts +13 -0
  265. package/mobile/placeholderAppId.ts +16 -0
  266. package/mobile/usageDescriptions.test.ts +27 -0
  267. package/mobile/usageDescriptions.ts +24 -0
  268. package/package.json +11 -11
  269. package/packageExports.test.ts +3 -18
  270. package/packageExportsMap.ts +3 -19
  271. package/piDependencyPins.test.ts +46 -0
  272. package/platformTest/BunTestReport.test.ts +60 -0
  273. package/platformTest/BunTestReport.ts +65 -0
  274. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  275. package/platformTest/IdleWatchedProcess.ts +73 -0
  276. package/platformTest/LinuxDockerTarget.ts +105 -0
  277. package/platformTest/PlatformTestRun.test.ts +64 -0
  278. package/platformTest/PlatformTestRun.ts +141 -0
  279. package/platformTest/PlatformTestTarget.ts +142 -0
  280. package/platformTest/TestSnapshot.test.ts +72 -0
  281. package/platformTest/TestSnapshot.ts +103 -0
  282. package/platformTest/WindowsSshTarget.ts +151 -0
  283. package/prompter.ts +3 -13
  284. package/qualityScanner.test.ts +9 -26
  285. package/qualityScanner.ts +200 -286
  286. package/recipeScanner.test.ts +7 -12
  287. package/recipeScanner.ts +9 -38
  288. package/repoIdentity.ts +2 -9
  289. package/routeSourceValidator.test.ts +0 -1
  290. package/routeSourceValidator.ts +7 -42
  291. package/scanInfo.ts +91 -225
  292. package/selectModel.ts +1 -2
  293. package/semver.ts +3 -5
  294. package/slicePlanner.test.ts +6 -24
  295. package/slicePlanner.ts +11 -32
  296. package/spinner.test.ts +3 -8
  297. package/spinner.ts +4 -16
  298. package/sshTunnel.ts +1 -6
  299. package/ssrScanner.test.ts +1 -6
  300. package/ssrScanner.ts +103 -113
  301. package/stripAnsi.ts +3 -0
  302. package/subspace.test.ts +19 -26
  303. package/subspace.ts +48 -115
  304. package/subspaceConfig.ts +3 -11
  305. package/testHelpers.ts +194 -0
  306. package/transforms/asyncDefaultExportDetector.ts +3 -11
  307. package/transforms/barrelAnalyzer.ts +43 -135
  308. package/transforms/barrelImportsPlugin.ts +38 -175
  309. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  310. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  311. package/transforms/moduleSyntax.ts +32 -0
  312. package/transforms/rscUseClientTransform.ts +42 -20
  313. package/transforms/serverEnvPlugin.test.ts +103 -0
  314. package/transforms/serverEnvPlugin.ts +41 -0
  315. package/transforms/transforms.test.ts +166 -44
  316. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  317. package/transforms/useClientBundlePlugin.ts +6 -23
  318. package/typeChecker.ts +7 -63
  319. package/types.ts +2 -0
  320. package/uploadRelease.ts +10 -25
  321. package/workflow/artifacts.ts +18 -0
  322. package/workflow/executor.test.ts +1 -12
  323. package/workflow/executor.ts +101 -110
  324. package/workflow/index.ts +0 -1
  325. package/workflow/moduleIndex.test.ts +4 -15
  326. package/workflow/moduleIndex.ts +9 -49
  327. package/workflow/plan.ts +18 -25
  328. package/workflow/render.ts +80 -96
  329. package/workflow/rolloutGate.test.ts +1 -1
  330. package/workflow/source.ts +52 -69
  331. package/workflow/sourceAst.ts +50 -0
  332. package/workspaceLayout.test.ts +18 -3
  333. package/workspaceLayout.ts +22 -23
  334. package/abstractCompactor.ts +0 -112
  335. package/aiEditor.test.ts +0 -60
  336. package/aiEditor.ts +0 -387
  337. package/applicationReleasePackager.ts +0 -209
  338. package/capacitorApp.test.ts +0 -372
  339. package/capacitorApp.ts +0 -1648
  340. package/commandDecorators/commandMeta.ts +0 -7
  341. package/commandDecorators/types.ts +0 -10
  342. package/getDirname.ts +0 -4
  343. package/guideline.ts +0 -19
  344. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  345. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  346. package/workflow/primitive.ts +0 -59
@@ -19,8 +19,6 @@ export interface DevStabilityHost {
19
19
  }
20
20
 
21
21
  export interface DevStabilityHmrProbe {
22
- /** The live socket — a reconnect replaces it, so read it rather than holding on to one. */
23
- readonly ws: WebSocket;
24
22
  readonly reconnects: number;
25
23
  messages: unknown[];
26
24
  mark(): number;
@@ -43,7 +41,6 @@ export class DevStabilityHarness {
43
41
  #portAllocation: Promise<{ port: number; offset: number }> | null = null;
44
42
  #boundPort: number | null = null;
45
43
  #host: DevStabilityHost | null = null;
46
- /** What the last HTTP poll saw, so a timeout can say which port answered and with what. */
47
44
  #lastHttpProbe: { port: number; body: string | null } = { port: 0, body: null };
48
45
 
49
46
  constructor({
@@ -63,47 +60,31 @@ export class DevStabilityHarness {
63
60
 
64
61
  async createFixture(): Promise<DevStabilityFixture> {
65
62
  await rm(this.appDir, { recursive: true, force: true });
66
- await Promise.all([
67
- mkdir(path.join(this.appDir, "page"), { recursive: true }),
68
- mkdir(path.join(this.appDir, "common"), { recursive: true }),
69
- mkdir(path.join(this.appDir, "srvkit"), { recursive: true }),
70
- mkdir(path.join(this.appDir, "ui"), { recursive: true }),
71
- mkdir(path.join(this.appDir, "webkit"), { recursive: true }),
72
- mkdir(path.join(this.appDir, "lib"), { recursive: true }),
73
- mkdir(path.join(this.appDir, "env"), { recursive: true }),
74
- mkdir(path.join(this.appDir, "public"), { recursive: true }),
75
- ]);
76
- await Promise.all([
77
- this.writeFile(
78
- "main.ts",
79
- `import { AkanApp } from "akanjs/server";
63
+ await Promise.all(
64
+ ["page", "common", "srvkit", "ui", "webkit", "lib", "env", "public"].map((dir) =>
65
+ mkdir(path.join(this.appDir, dir), { recursive: true }),
66
+ ),
67
+ );
68
+ const files: { [relativePath: string]: string } = {
69
+ "main.ts": `import { AkanApp } from "akanjs/server";
80
70
 
81
71
  const run = async () => {
82
72
  await new AkanApp("./server").start();
83
73
  };
84
74
  void run();
85
75
  `,
86
- ),
87
- this.writeFile(
88
- "akan.config.ts",
89
- `import type { AppConfig } from "akanjs";
76
+ "akan.config.ts": `import type { AppConfig } from "akanjs";
90
77
 
91
78
  const config: AppConfig = {};
92
79
  export default config;
93
80
  `,
94
- ),
95
- this.writeFile(
96
- "package.json",
97
- `{
81
+ "package.json": `{
98
82
  "type": "module",
99
83
  "name": "${this.appName}",
100
84
  "version": "0.0.1"
101
85
  }
102
86
  `,
103
- ),
104
- this.writeFile(
105
- "tsconfig.json",
106
- `{
87
+ "tsconfig.json": `{
107
88
  "extends": "../../tsconfig.json",
108
89
  "compilerOptions": {
109
90
  "allowJs": true,
@@ -115,33 +96,18 @@ export default config;
115
96
  "include": ["./**/*.ts", "./**/*.tsx"]
116
97
  }
117
98
  `,
118
- ),
119
- this.writeFile(
120
- "env/env.client.ts",
121
- `export const env = {} as const;
99
+ "env/env.client.ts": `export const env = {} as const;
122
100
  `,
123
- ),
124
- this.writeFile(
125
- "env/env.server.ts",
126
- `export const env = {} as const;
101
+ "env/env.server.ts": `export const env = {} as const;
127
102
  `,
128
- ),
129
- this.writeFile(
130
- "env/env.server.testing.ts",
131
- `export { env } from "./env.server";
103
+ "env/env.server.testing.ts": `export { env } from "./env.server";
132
104
  `,
133
- ),
134
- this.writeFile(
135
- "lib/option.ts",
136
- `import { AkanOption } from "akanjs/server";
105
+ "lib/option.ts": `import { AkanOption } from "akanjs/server";
137
106
 
138
107
  export type ModulesOptions = Record<string, never>;
139
108
  export const option = new AkanOption<ModulesOptions>();
140
109
  `,
141
- ),
142
- this.writeFile(
143
- "server.ts",
144
- `import { AkanServer, AkanLib } from "akanjs/server";
110
+ "server.ts": `import { AkanServer, AkanLib } from "akanjs/server";
145
111
  import { backendMarker } from "./srvkit/backendMarker";
146
112
 
147
113
  void backendMarker;
@@ -155,20 +121,14 @@ export const server = new AkanServer("${this.appName}", {
155
121
  serveDomain: "localhost",
156
122
  } as never, undefined, lib);
157
123
  `,
158
- ),
159
- this.writeFile(
160
- "page/_layout.tsx",
161
- `import "./styles.css";
124
+ "page/_layout.tsx": `import "./styles.css";
162
125
  import { layout } from "akanjs/client";
163
126
 
164
127
  export default layout().render(({ children }) => {
165
128
  return <>{children}</>;
166
129
  });
167
130
  `,
168
- ),
169
- this.writeFile(
170
- "page/_index.tsx",
171
- `import { page } from "akanjs/client";
131
+ "page/_index.tsx": `import { page } from "akanjs/client";
172
132
  import { marker } from "../common/marker";
173
133
  import { ClientMarker } from "../ui/ClientMarker";
174
134
 
@@ -182,13 +142,8 @@ export default page().render(() => {
182
142
  );
183
143
  });
184
144
  `,
185
- ),
186
- // A second route exists so a test can ask for a page the backend has *never* built, at a moment of
187
- // its choosing. Route clients are built on demand and cached, so on a one-route fixture the first
188
- // request of a test is the only one that reaches the builder at all.
189
- this.writeFile(
190
- "page/second/_index.tsx",
191
- `import { page } from "akanjs/client";
145
+ // Route clients are built on demand and cached, so only a never-requested route reaches the builder.
146
+ "page/second/_index.tsx": `import { page } from "akanjs/client";
192
147
  import { ClientMarker } from "../../ui/ClientMarker";
193
148
 
194
149
  export default page().render(() => {
@@ -200,34 +155,19 @@ export default page().render(() => {
200
155
  );
201
156
  });
202
157
  `,
203
- ),
204
- this.writeFile(
205
- "page/styles.css",
206
- `main {
158
+ "page/styles.css": `main {
207
159
  color: black;
208
160
  }
209
161
  `,
210
- ),
211
- this.writeFile(
212
- "common/marker.ts",
213
- `export const marker = "initial-shared-marker";
162
+ "common/marker.ts": `export const marker = "initial-shared-marker";
214
163
  `,
215
- ),
216
- this.writeFile(
217
- "srvkit/backendMarker.ts",
218
- `export const backendMarker = "initial-backend-marker";
164
+ "srvkit/backendMarker.ts": `export const backendMarker = "initial-backend-marker";
219
165
  `,
220
- ),
221
- this.writeFile(
222
- "lib/_fixture/fixture.service.ts",
223
- `import { serve } from "akanjs/service";
166
+ "lib/_fixture/fixture.service.ts": `import { serve } from "akanjs/service";
224
167
 
225
168
  export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
226
169
  `,
227
- ),
228
- this.writeFile(
229
- "lib/_fixture/fixture.signal.ts",
230
- `import { endpoint, internal } from "akanjs/signal";
170
+ "lib/_fixture/fixture.signal.ts": `import { endpoint, internal } from "akanjs/signal";
231
171
 
232
172
  import * as srv from "../srv";
233
173
 
@@ -235,10 +175,7 @@ export class FixtureInternal extends internal(srv.fixture, () => ({})) {}
235
175
 
236
176
  export class FixtureEndpoint extends endpoint(srv.fixture, () => ({})) {}
237
177
  `,
238
- ),
239
- this.writeFile(
240
- "lib/_fixture/fixture.dictionary.ts",
241
- `import { serviceDictionary } from "akanjs/dictionary";
178
+ "lib/_fixture/fixture.dictionary.ts": `import { serviceDictionary } from "akanjs/dictionary";
242
179
 
243
180
  import type { FixtureEndpoint } from "./fixture.signal";
244
181
 
@@ -249,32 +186,19 @@ export const dictionary = serviceDictionary(["en", "ko"])
249
186
  removeMe: ["Remove Me", "삭제 예정"],
250
187
  });
251
188
  `,
252
- ),
253
- this.writeFile(
254
- "ui/ClientMarker.tsx",
255
- `export function ClientMarker() {
189
+ "ui/ClientMarker.tsx": `export function ClientMarker() {
256
190
  return <p data-testid="client-marker">initial-client-marker</p>;
257
191
  }
258
192
  `,
259
- ),
260
- this.writeFile(
261
- "webkit/useMarker.ts",
262
- `export const useMarker = () => "initial-webkit-marker";
193
+ "webkit/useMarker.ts": `export const useMarker = () => "initial-webkit-marker";
263
194
  `,
264
- ),
265
- ]);
195
+ };
196
+ await Promise.all(Object.entries(files).map(([relativePath, contents]) => this.writeFile(relativePath, contents)));
266
197
  const port = await this.resolvePort();
267
198
  return { appName: this.appName, appDir: this.appDir, workspaceRoot: this.workspaceRoot, port };
268
199
  }
269
200
 
270
- /**
271
- * Stop the dev server and delete the fixture, within a budget that cannot outlive the hook awaiting it.
272
- *
273
- * Overrunning the `afterEach` budget costs more than one red test. Bun fails the test that had *already
274
- * passed*, and the fixture is dropped from the cleanup list either way — so its dev host keeps running,
275
- * holding ports and shifting the app index, and the tests after it fail too. That cascade is what turned
276
- * one slow cleanup into three failures in a parallel shard.
277
- */
201
+ // Bounded: an overrun `afterEach` fails the already-passed test and leaves this host running into the next ones.
278
202
  async cleanup(): Promise<void> {
279
203
  const startedAt = Date.now();
280
204
  const finished = await Promise.race([
@@ -286,8 +210,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
286
210
  console.warn(`[harness] ${this.appName} cleanup took ${Date.now() - startedAt}ms`);
287
211
  return;
288
212
  }
289
- // Give up on the orderly path, but not on the process: killing the tracked child directly involves no
290
- // `ps` and cannot itself hang, which is what the orderly path just demonstrated it can do.
213
+ // Killing the tracked child directly needs no `ps`, so it cannot hang the way the orderly path just did.
291
214
  this.#host?.proc.kill("SIGKILL");
292
215
  this.#host = null;
293
216
  console.warn(
@@ -304,17 +227,10 @@ export const dictionary = serviceDictionary(["en", "ko"])
304
227
  }
305
228
 
306
229
  static readonly #slowCleanupMs = 10_000;
307
- /** Comfortably inside the suite's 60s hook budget, so a hang is reported by the harness — which knows
308
- * which phase stalled — rather than by Bun, which only knows that a hook did not return. */
230
+ // Inside the suite's 60s hook budget, so the harness names the stalled phase before Bun's bare hook timeout.
309
231
  static readonly #cleanupBudgetMs = 30_000;
310
232
 
311
- /**
312
- * Names a phase that is still running, rather than only reporting one that finished.
313
- *
314
- * A cleanup that never returns is reported by Bun as a bare `a beforeEach/afterEach hook timed out`
315
- * against the test that had already passed, with nothing at all about where it hung — so any
316
- * end-of-phase timing is exactly the information a hang destroys.
317
- */
233
+ // Bun reports a hung hook with no location, so warn while a phase is still running, not after it ends.
318
234
  static async #watched<T>(label: string, work: () => Promise<T>): Promise<T> {
319
235
  const timer = setTimeout(
320
236
  () => console.warn(`[harness] ${label} still running after ${DevStabilityHarness.#slowCleanupMs}ms`),
@@ -350,17 +266,13 @@ export const dictionary = serviceDictionary(["en", "ko"])
350
266
  env: {
351
267
  ...process.env,
352
268
  AKAN_VERBOSE: "1",
353
- // The dev-plan/hmr assertions match verbose-level Logger lines; without this the log
354
- // stream only carries info/warn/error and those waits time out with empty tails.
269
+ // The dev-plan/hmr assertions match verbose-level lines.
355
270
  AKAN_PUBLIC_LOG_LEVEL: "verbose",
356
271
  NODE_NO_WARNINGS: "1",
272
+ //? CI's `.env` names the branch; any env but local makes `akan start` ssh-tunnel to that cluster's Redis.
273
+ AKAN_PUBLIC_ENV: "local",
357
274
  PORT_OFFSET: String(offset),
358
- // Pinned, not predicted. `getDevPort()` derives the port from this fixture's index in the `apps/`
359
- // listing, and a parallel run adds and removes fixtures constantly — so the index moved between the
360
- // allocation here and the host reading it, and again on every restart. Tests that merely *wait* on a
361
- // port recovered by adopting the logged one, but a test that has to reserve a port before boot (the
362
- // occupied-ws-port test blocks `port + 10_000`) had no way to be right, and spent 60s waiting for a
363
- // fallback that could not happen because the gateway had gone to a different port entirely.
275
+ // Pinned: `getDevPort()` derives from the fixture's index in `apps/`, which parallel runs keep moving.
364
276
  AKAN_DEV_PORT: String(port),
365
277
  ...env,
366
278
  },
@@ -387,19 +299,15 @@ export const dictionary = serviceDictionary(["en", "ko"])
387
299
  const host: DevStabilityHost = {
388
300
  proc,
389
301
  logs,
390
- markLog: () => markLog(logs),
302
+ markLog: () => logs.join("").length,
391
303
  waitForLog: (pattern, waitMs) =>
392
- DevStabilityHarness.#timed(`waitForLog ${pattern}`, () => waitForLog(logs, pattern, waitMs)),
304
+ DevStabilityHarness.#timed(`waitForLog ${pattern}`, () => waitForLogSince(logs, 0, pattern, waitMs)),
393
305
  waitForLogSince: (mark, pattern, waitMs) =>
394
306
  DevStabilityHarness.#timed(`waitForLogSince ${pattern}`, () => waitForLogSince(logs, mark, pattern, waitMs)),
395
307
  stop: async () => {
396
308
  await DevStabilityHarness.#dumpLogs(this.appName, logs);
397
- // The host runs under `bash -lc`, so killing `proc` only kills the shell: the dev host, its
398
- // builder and its backend outlive it as orphans that keep watching a deleted fixture app and
399
- // interfere with later tests. Collect the descendants first, then signal all of them.
400
- //
401
- // The tracked child is always signalled directly as well, because `descendantPids` comes back empty
402
- // when the `ps` behind it times out — and that path must still take the shell down.
309
+ // Under `bash -lc`, killing `proc` only kills the shell, so every descendant is signalled too; `proc`
310
+ // itself is signalled directly because `descendantPids` is empty when `ps` times out.
403
311
  const pids = await DevStabilityHarness.#watched("descendants", () =>
404
312
  DevStabilityHarness.descendantPids(proc.pid),
405
313
  );
@@ -414,8 +322,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
414
322
  DevStabilityHarness.#signalPids(survivors, "SIGKILL");
415
323
  DevStabilityHarness.#signalPids(pids, "SIGKILL");
416
324
  proc.kill("SIGKILL");
417
- // Signalling is not reaping. Returning while a build worker is still alive leaves it writing
418
- // into `.akan/artifact` while the caller's `rm -rf` walks the same tree.
325
+ // Signalling is not reaping: a live build worker would keep writing into the tree the caller deletes next.
419
326
  const signalled = [...new Set([...pids, ...survivors])];
420
327
  const gone = await DevStabilityHarness.#watched("await gone", () =>
421
328
  DevStabilityHarness.#waitForPidsGone(signalled, 15_000),
@@ -439,13 +346,6 @@ export const dictionary = serviceDictionary(["en", "ko"])
439
346
  this.#host = null;
440
347
  }
441
348
 
442
- /**
443
- * Write a host's whole log to `AKAN_DEV_STABILITY_LOG_DIR` when it is set.
444
- *
445
- * A failing wait prints only a tail, and a *passing* run prints nothing — so a stall that stays inside
446
- * the timeout budget is invisible, which is exactly the state a slow-but-green suite hides. Off unless
447
- * the variable is set, since the suite otherwise produces megabytes per round.
448
- */
449
349
  static async #dumpLogs(appName: string, logs: string[]): Promise<void> {
450
350
  const dir = process.env.AKAN_DEV_STABILITY_LOG_DIR;
451
351
  if (!dir) return;
@@ -459,49 +359,22 @@ export const dictionary = serviceDictionary(["en", "ko"])
459
359
  await writeFile(target, contents);
460
360
  }
461
361
 
462
- /**
463
- * Apply an edit and return once the dev server has demonstrably *seen* it, re-applying it if it has not.
464
- *
465
- * Bun's recursive `fs.watch` reports about one path per coalescing window and discards the rest, so an
466
- * edit landing in the same window as a build's write burst used to be dropped entirely — no rebuild, no
467
- * HMR, no error. In a 3-way parallel run that failed the same test in all three shards, with 60 seconds
468
- * of *completely empty* log output after the edit.
469
- *
470
- * `HmrWatcher` now resolves changes against a `SourceMtimeIndex` instead of trusting those payloads, so
471
- * this no longer settles before editing: edits deliberately land right after the previous build's burst,
472
- * which is exactly the case that used to be lost. `retried` staying 0 is therefore evidence the fix
473
- * holds, where before it only meant the settle had dodged the window.
474
- *
475
- * The retry stays as the regression detector. What it waits for is evidence the watcher saw the edit,
476
- * not the outcome the caller cares about: evidence arrives within a debounce, while an outcome can
477
- * legitimately take far longer than any retry budget — so callers keep their own assertions, patterns
478
- * and timeouts exactly as they were. An edit the dev server genuinely fails to act on exhausts every
479
- * attempt and fails the test rather than being retried into a pass.
480
- */
362
+ // Bun's recursive `fs.watch` can drop an edit landing in a build's write burst, so re-apply it until the dev
363
+ // server logs evidence of seeing it (a regression detector: `retried` should stay 0); callers assert outcomes.
481
364
  async editUntilSeen(
482
365
  host: DevStabilityHost,
483
366
  mutate: (attempt: number) => Promise<void>,
484
367
  {
485
368
  evidence = DevStabilityHarness.#editEvidence,
486
369
  attempts = 3,
487
- // Generous on purpose. A dropped event yields *nothing, ever*, so waiting longer never weakens the
488
- // discrimination — while a budget tight enough to mistake a loaded machine for a dropped event makes
489
- // the retry actively harmful, since re-applying an edit is not always cheap (a config rewrite costs a
490
- // whole dev-host restart, and four of those overran a 120s test).
370
+ // Generous: a dropped event never arrives late, while a needless retry can cost a whole dev-host restart.
491
371
  evidenceTimeoutMs = 20_000,
492
- settleMs = 0,
493
- retryDelayMs = 750,
494
372
  }: {
495
373
  evidence?: RegExp;
496
374
  attempts?: number;
497
375
  evidenceTimeoutMs?: number;
498
- settleMs?: number;
499
- retryDelayMs?: number;
500
376
  } = {},
501
377
  ): Promise<{ mark: number; attempts: number; evidence: RegExpMatchArray }> {
502
- // Kept as an escape hatch for a caller that needs spacing for its own reasons; the watcher no longer
503
- // needs it, so the default is 0 and edits land inside the burst window on purpose.
504
- if (settleMs > 0) await wait(settleMs);
505
378
  for (let attempt = 1; ; attempt++) {
506
379
  const mark = host.markLog();
507
380
  await mutate(attempt);
@@ -515,12 +388,10 @@ export const dictionary = serviceDictionary(["en", "ko"])
515
388
  throw new Error(
516
389
  `Dev server never reacted to ${attempts} edit(s) of ${this.appName}: waited ${evidenceTimeoutMs}ms each for ${evidence}`,
517
390
  );
518
- await wait(retryDelayMs);
391
+ await wait(750);
519
392
  }
520
393
  }
521
394
 
522
- /** Every classified change logs a dev plan before anything acts on it, and an idle-suspended host logs a
523
- * wake first, so either line proves the watcher saw the edit. */
524
395
  static readonly #editEvidence = /\[dev-plan\] generation=\d+|\[idle-suspend\] waking/;
525
396
  static #observedEdits = 0;
526
397
  static #retriedEdits = 0;
@@ -531,15 +402,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
531
402
 
532
403
  static readonly #waitDurations: { label: string; ms: number }[] = [];
533
404
 
534
- /**
535
- * Time one wait so a run that dies of *cumulative* slowness can say where the time went.
536
- *
537
- * Each wait here is individually bounded, but their budgets sum past the per-test timeout — `startHost`
538
- * (60s) plus one `waitForLogSince` (60s) plus one `waitForHttpText` (60s) already reaches 180s. So a
539
- * loaded round can kill a test without any single wait failing, and Bun reports only "this test timed out",
540
- * naming neither the step nor how close the others came. That produced failures that looked like they
541
- * rotated between tests at random, when what rotates is which long test happened to be slowest.
542
- */
405
+ // Bounded waits can still sum past the test timeout, and Bun then names no step, so each one is recorded.
543
406
  static async #timed<T>(label: string, work: () => Promise<T>): Promise<T> {
544
407
  const at = performance.now();
545
408
  try {
@@ -549,7 +412,6 @@ export const dictionary = serviceDictionary(["en", "ko"])
549
412
  }
550
413
  }
551
414
 
552
- /** The slowest waits observed, worst first, for an end-of-run report. */
553
415
  static waitStats(limit = 5): { label: string; ms: number }[] {
554
416
  return [...DevStabilityHarness.#waitDurations].sort((a, b) => b.ms - a.ms).slice(0, limit);
555
417
  }
@@ -568,8 +430,6 @@ export const dictionary = serviceDictionary(["en", "ko"])
568
430
  const body = await this.tryWaitForHttpText(text, timeoutMs);
569
431
  if (body) return body;
570
432
  const { port, body: last } = this.#lastHttpProbe;
571
- // The host's own tail matters as much as the response: a 60s wait that ends in a served error page says
572
- // what the browser saw, but only the dev server's log says why it got into that state.
573
433
  const tail = (this.#host?.logs.join("") ?? "").slice(-2_000);
574
434
  throw new Error(
575
435
  `Timed out waiting for HTTP text ${String(text)} after ${timeoutMs}ms on port ${port}; ` +
@@ -587,14 +447,9 @@ export const dictionary = serviceDictionary(["en", "ko"])
587
447
  async #pollHttpText(text: string | RegExp, timeoutMs: number): Promise<string | null> {
588
448
  const started = Date.now();
589
449
  while (Date.now() - started < timeoutMs) {
590
- // Re-resolved every poll rather than once up front. A dev-host restart re-runs `getDevPort()` and can
591
- // bind a different port, and quietly polling a port nobody listens on for the rest of the budget is
592
- // indistinguishable from a page that never updated — it cost 60s and a misleading failure.
450
+ // Re-resolved each poll: a dev-host restart can bind a different port.
593
451
  const port = await this.resolvePort();
594
- // Bounded per request, because the deadline above is only checked *between* iterations. A dev server
595
- // mid-recovery accepts the connection and then never answers, so an unbounded `fetch` parks here for
596
- // as long as the peer likes: measured 225561ms against this method's own 60000ms budget, which then
597
- // ate the whole 180s test timeout and reported "this test timed out" with no other information.
452
+ // Bounded per request: a recovering dev server can accept a connection and never answer.
598
453
  const budget = Math.max(250, Math.min(5_000, timeoutMs - (Date.now() - started)));
599
454
  const body = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(budget) })
600
455
  .then((res) => res.text())
@@ -632,15 +487,8 @@ export const dictionary = serviceDictionary(["en", "ko"])
632
487
  throw new Error("Timed out connecting HMR websocket");
633
488
  }
634
489
 
635
- /**
636
- * An HMR probe that reconnects, because the browser it stands in for does.
637
- *
638
- * A raw socket dies whenever the backend restarts, and a dead socket is indistinguishable from a quiet
639
- * one: `waitForMessageSince` polls an array nothing can ever append to, then reports a plain timeout a
640
- * minute later. In a 3-way parallel run that was a third of all failures — the diagnostic read
641
- * `socket=closed since-mark=[] total=3` — and it says nothing about the product, since the real client
642
- * reconnects and picks the stream back up (`akanjs/server/hmr/clientScript.ts`).
643
- */
490
+ // Reconnects like the real client (`akanjs/server/hmr/clientScript.ts`): a socket a backend restart dropped
491
+ // would otherwise read as silence.
644
492
  async connectHmrProbe(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<DevStabilityHmrProbe> {
645
493
  const messages: unknown[] = [];
646
494
  let socket = await this.connectHmr(timeoutMs);
@@ -656,9 +504,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
656
504
  }
657
505
  });
658
506
  ws.addEventListener("close", () => {
659
- // Bounded, and with a short budget: the last close of a probe's life is the dev server being torn
660
- // down in `cleanup()`, and an unbounded retry there would keep reconnecting at a dead port for a
661
- // minute after the test ended.
507
+ // Bounded and short: the last close is the teardown in `cleanup()`, where retrying would poll a dead port.
662
508
  if (closedByCaller || reconnects >= DevStabilityHarness.#maxProbeReconnects) return;
663
509
  void this.connectHmr(Math.min(timeoutMs, 5_000))
664
510
  .then((next) => {
@@ -670,16 +516,12 @@ export const dictionary = serviceDictionary(["en", "ko"])
670
516
  socket = next;
671
517
  listen(next);
672
518
  })
673
- // A dev server that is gone for good is the caller's problem to notice through its own waits,
674
- // not something to throw from an event listener.
519
+ // A server gone for good surfaces through the caller's own waits; never throw from a listener.
675
520
  .catch(() => undefined);
676
521
  });
677
522
  };
678
523
  listen(socket);
679
524
  return {
680
- get ws() {
681
- return socket;
682
- },
683
525
  get reconnects() {
684
526
  return reconnects;
685
527
  },
@@ -716,60 +558,11 @@ export const dictionary = serviceDictionary(["en", "ko"])
716
558
  }
717
559
  }
718
560
 
719
- async waitForHmrMessage(
720
- ws: WebSocket,
721
- predicate: (message: unknown) => boolean,
722
- timeoutMs = DEFAULT_TIMEOUT_MS,
723
- ): Promise<unknown> {
724
- return await new Promise((resolve, reject) => {
725
- const timeout = setTimeout(() => {
726
- ws.removeEventListener("message", onMessage);
727
- reject(new Error("Timed out waiting for HMR message"));
728
- }, timeoutMs);
729
- const onMessage = (event: MessageEvent) => {
730
- const raw = typeof event.data === "string" ? event.data : "";
731
- let message: unknown;
732
- try {
733
- message = JSON.parse(raw);
734
- } catch {
735
- return;
736
- }
737
- if (!predicate(message)) return;
738
- clearTimeout(timeout);
739
- ws.removeEventListener("message", onMessage);
740
- resolve(message);
741
- };
742
- ws.addEventListener("message", onMessage);
743
- });
744
- }
745
-
746
- /**
747
- * The port this fixture's dev server is reachable on — the one the gateway actually bound once it is
748
- * up, and a probed prediction before that.
749
- *
750
- * It used to recompute `8282 + appIndex + randomOffset` from the `apps/` listing on every call, which
751
- * is wrong twice over. `appIndex` moves whenever any *other* fixture appears or disappears, and two
752
- * suites running in parallel create and delete one every few seconds — so the answer changed
753
- * mid-test and every HTTP wait then polled a port nobody was listening on. And the random offset
754
- * collides, roughly 1-in-N per pair, while the gateway has no fallback for its http port
755
- * (`AkanApp.start` logs "already in use" and exits), so a collision reads as a boot timeout with no
756
- * hint of a port problem. Both failures are indistinguishable from a product regression, which is
757
- * most of why a red run of this suite could not be told apart from noise.
758
- */
759
561
  async resolvePort(): Promise<number> {
760
562
  return this.#gatewayPortFromLogs() ?? this.#boundPort ?? (await this.#allocatePort()).port;
761
563
  }
762
564
 
763
- /**
764
- * The port the *most recent* gateway bound, or null before one has said.
765
- *
766
- * Read fresh rather than adopted once, because the port can move mid-test: a config edit restarts the dev
767
- * host, and the replacement recomputes its own port from the `apps/` listing (`AppExecutor.getDevPort`) —
768
- * which a parallel run has changed in the meantime. Holding the boot-time port meant the config test
769
- * watched the restart succeed and then timed out fetching from the address the *old* gateway had used.
770
- * (Worth knowing outside the tests too: adding or removing an app during a session moves a running dev
771
- * server's port at its next restart.)
772
- */
565
+ // The latest match: a restarted dev host recomputes its port from the `apps/` listing, which may have moved.
773
566
  #gatewayPortFromLogs(): number | null {
774
567
  const logs = this.#host?.logs;
775
568
  if (!logs) return null;
@@ -779,14 +572,11 @@ export const dictionary = serviceDictionary(["en", "ko"])
779
572
  return latest;
780
573
  }
781
574
 
782
- /** Offsets are handed out in stride steps from a pid-seeded cursor and probed against the OS, never
783
- * drawn at random. Forward-only means no two harnesses in one process share an offset even while the
784
- * first host is still shutting down and holding its port; the probe covers everything outside this
785
- * process; and the pid seed keeps two concurrent runs out of each other's band. */
575
+ // A pid-seeded, forward-only cursor probed against the OS: no two harnesses in a process share an offset while
576
+ // one still shuts down, and concurrent runs start in different places.
786
577
  static readonly portOffsetMin = 3_000;
787
578
  static readonly portOffsetMax = 4_000;
788
- /** The app's port is `8282 + appIndex + offset`, so offsets one apart alias each other as soon as a
789
- * fixture shifts the index. A stride wider than any plausible drift keeps them distinct. */
579
+ // The port is `8282 + appIndex + offset`: a stride wider than any index drift keeps neighbouring offsets apart.
790
580
  static readonly portOffsetStride = 4;
791
581
  static #portOffsetCursor: number | null = null;
792
582
 
@@ -807,8 +597,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
807
597
  );
808
598
  }
809
599
 
810
- /** Probed on the default interface, which is the strict test: a bind there fails if anything holds
811
- * the port on any address the app might pick. */
600
+ // The default interface is the strict test: a bind there fails if any address the app might pick is held.
812
601
  static async isPortFree(port: number): Promise<boolean> {
813
602
  try {
814
603
  Bun.serve({ port, fetch: () => new Response("probe") }).stop(true);
@@ -831,8 +620,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
831
620
  for (let attempt = 0; attempt < DevStabilityHarness.#portOffsetSlots; attempt++) {
832
621
  const offset = DevStabilityHarness.#nextPortOffset();
833
622
  const port = basePort + offset;
834
- // The gateway derives child 0's websocket port from its http port (`AkanApp` `#wsBasePort`), so
835
- // both have to be free for a boot to be clean.
623
+ // Child 0's websocket port is `port + 10_000` (`AkanApp` `#wsBasePort`), so both must be free.
836
624
  if ((await DevStabilityHarness.isPortFree(port)) && (await DevStabilityHarness.isPortFree(port + 10_000)))
837
625
  return { port, offset };
838
626
  }
@@ -841,8 +629,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
841
629
  );
842
630
  }
843
631
 
844
- /** Mirror the CLI's `getDevPort()` exactly (apps = directories containing akan.config.ts,
845
- * locale-sorted): counting stray entries like .DS_Store would put us one port off the gateway. */
632
+ // Mirrors `getDevPort()`: dirs holding an akan.config.ts, locale-sorted; a stray entry would shift the port.
846
633
  async #appIndex(): Promise<number> {
847
634
  const appsDir = path.join(this.workspaceRoot, "apps");
848
635
  const entries = await readdir(appsDir, { withFileTypes: true }).catch(() => []);
@@ -859,7 +646,6 @@ export const dictionary = serviceDictionary(["en", "ko"])
859
646
  return Math.max(apps.indexOf(this.appName), 0);
860
647
  }
861
648
 
862
- /** Stop predicting once the gateway has said what it bound. */
863
649
  async #adoptBoundPort(host: DevStabilityHost): Promise<void> {
864
650
  const match = await host
865
651
  .waitForLog(/AkanApp gateway is running on port http:\/\/localhost:(\d+)/, 30_000)
@@ -872,13 +658,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
872
658
  console.warn(`[harness] ${this.appName} bound port ${bound}, not the predicted ${predicted}`);
873
659
  }
874
660
 
875
- /**
876
- * Fixtures and dev hosts left behind by a test process that died before `cleanup()` could run.
877
- *
878
- * They keep watching a deleted app, hold their ports, and — because the fixture directory is still in
879
- * `apps/` — shift the app index every other harness predicts its port from. Only fixtures whose
880
- * owning test pid is gone are swept, so a concurrent run's live fixtures are never touched.
881
- */
661
+ // Only fixtures whose owning test pid is gone, so a concurrent run's live fixtures are never touched.
882
662
  static async sweepAbandonedFixtures(workspaceRoot: string): Promise<string[]> {
883
663
  const appsDir = path.join(workspaceRoot, "apps");
884
664
  const entries = await readdir(appsDir, { withFileTypes: true }).catch(() => []);
@@ -908,15 +688,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
908
688
  }
909
689
  }
910
690
 
911
- /**
912
- * Resident set size of the whole `akan start` process tree, which is what a dev sandbox actually
913
- * costs: supervisor, builder, gateway, replica and rsc worker. `excludeBuilder` drops the bundler
914
- * process, whose growth across saves is `Bun.build` native arena retention rather than a leak the
915
- * other processes could be blamed for.
916
- */
917
- // Matched precisely, not by directory: the disposable build worker lives at
918
- // `incrementalBuilder/buildBatch.proc.ts`, so a substring match on `incrementalBuilder` would count
919
- // the worker as the watcher and make every builder measurement depend on spawn timing.
691
+ // Not `incrementalBuilder`: the disposable worker `incrementalBuilder/buildBatch.proc.ts` would match too.
920
692
  static readonly #builderCmd = "incrementalBuilder.proc";
921
693
  static readonly #buildWorkerCmd = "buildBatch.proc";
922
694
 
@@ -936,15 +708,10 @@ export const dictionary = serviceDictionary(["en", "ko"])
936
708
  );
937
709
  }
938
710
 
939
- /**
940
- * The long-lived builder process on its own: its RSS is what bundler-arena retention used to move,
941
- * and its pid is what changes when the host recycles it, so a bounded-builder assertion needs both.
942
- */
943
711
  static async builderProcess(rootPid: number): Promise<{ pid: number; rssBytes: number } | null> {
944
712
  return DevStabilityHarness.#findProcess(rootPid, DevStabilityHarness.#builderCmd);
945
713
  }
946
714
 
947
- /** The disposable per-generation build worker, which should only exist while a build is running. */
948
715
  static async buildWorkerProcess(rootPid: number): Promise<{ pid: number; rssBytes: number } | null> {
949
716
  return DevStabilityHarness.#findProcess(rootPid, DevStabilityHarness.#buildWorkerCmd);
950
717
  }
@@ -956,26 +723,15 @@ export const dictionary = serviceDictionary(["en", "ko"])
956
723
  return found ? { pid: found.pid, rssBytes: found.rssKb * 1024 } : null;
957
724
  }
958
725
 
959
- /** Pids of `rootPid` and everything under it, deepest first, so callers can signal children before parents. */
726
+ // Deepest first, so callers signal children before parents.
960
727
  static async descendantPids(rootPid: number | undefined): Promise<number[]> {
961
728
  if (!rootPid) return [];
962
729
  const pids = DevStabilityHarness.#collectDescendants(await DevStabilityHarness.#psRowsOrEmpty(), rootPid);
963
730
  return [...pids].reverse();
964
731
  }
965
732
 
966
- /**
967
- * `ps` output as rows, or `null` when `ps` did not answer in time.
968
- *
969
- * Bounded because the unbounded version hung indefinitely under parallel load, and it was the single
970
- * root cause of every remaining cleanup failure: a `ps` that never returns became an `afterEach` timeout,
971
- * and once a `beforeAll` timeout that cost an entire shard — 0 of 16 tests ran. The watchdog named it
972
- * outright (`descendants still running after 10000ms`). It also spawns `ps` directly rather than through
973
- * `Bun.$`, whose hung shells were what the runner then reported as `killed 1 dangling process`.
974
- *
975
- * `null` rather than `[]` on purpose: an empty list is a legitimate answer to "is the builder running",
976
- * so collapsing the two would let a measurement read a timeout as "no such process" and quietly assert
977
- * the opposite of what it meant to.
978
- */
733
+ // `ps` hangs under parallel load, so it is bounded and spawned directly (a hung `Bun.$` shell leaks). `null`, not
734
+ // `[]`, means "could not look", which a measurement must never read as "nothing running".
979
735
  static readonly #psTimeoutMs = 5_000;
980
736
 
981
737
  static async #psRows(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }> | null> {
@@ -987,8 +743,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
987
743
  stdin: "ignore",
988
744
  });
989
745
  } catch (error) {
990
- // No `ps` at all — a slim container image such as `oven/bun` ships without procps. Same answer as
991
- // a timeout, and for the same reason: this is "could not look", not "nothing is running".
746
+ // Slim images such as `oven/bun` ship without procps.
992
747
  console.warn(`[harness] could not run ps: ${error instanceof Error ? error.message : String(error)}`);
993
748
  return null;
994
749
  }
@@ -1012,13 +767,10 @@ export const dictionary = serviceDictionary(["en", "ko"])
1012
767
  });
1013
768
  }
1014
769
 
1015
- /** For callers that kill: an incomplete list is survivable, because they also signal the tracked child
1016
- * directly, and hanging instead would cost the whole test. */
1017
770
  static async #psRowsOrEmpty(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }>> {
1018
771
  return (await DevStabilityHarness.#psRows()) ?? [];
1019
772
  }
1020
773
 
1021
- /** For callers that measure: a missing process list must fail loudly, never read as "nothing running". */
1022
774
  static async #psRowsOrThrow(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }>> {
1023
775
  const rows = (await DevStabilityHarness.#psRows()) ?? (await DevStabilityHarness.#psRows());
1024
776
  if (!rows) throw new Error("ps did not answer twice in a row; cannot measure the process tree");
@@ -1026,10 +778,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
1026
778
  }
1027
779
 
1028
780
  static #collectDescendants(rows: Array<{ pid: number; ppid: number }>, rootPid: number): Set<number> {
1029
- // Iterate to a fixpoint rather than a fixed depth: the tree is `bash -lc` -> `bun run` -> the `&&`
1030
- // shell -> dev host -> gateway -> replica -> rsc worker, and a pass only guarantees one new
1031
- // generation when `ps` happens to list parents before children. A capped walk silently leaves the
1032
- // deepest processes unsignalled, which is how orphaned dev hosts survived `stop()`.
781
+ // To a fixpoint, not a fixed depth: the tree is seven levels deep and `ps` lists it in no particular order.
1033
782
  const pids = new Set([rootPid]);
1034
783
  for (let added = 1; added > 0; ) {
1035
784
  added = 0;
@@ -1066,8 +815,6 @@ export async function waitForHmrMessageSince(
1066
815
  if (found) return found;
1067
816
  await wait(50);
1068
817
  }
1069
- // "Timed out" alone cannot distinguish the two cases that matter: the message was never published, or
1070
- // the socket died and nothing could have arrived. Both look identical from the message array.
1071
818
  throw new Error(
1072
819
  `Timed out waiting for HMR message since mark ${mark}${describeProbe ? ` (${describeProbe()})` : ""}`,
1073
820
  );
@@ -1087,16 +834,6 @@ export async function waitForNoHmrMessageSince(
1087
834
  }
1088
835
  }
1089
836
 
1090
- export async function waitForLog(
1091
- logs: string[],
1092
- pattern: RegExp,
1093
- timeoutMs = DEFAULT_TIMEOUT_MS,
1094
- ): Promise<RegExpMatchArray> {
1095
- return await waitForLogSince(logs, 0, pattern, timeoutMs);
1096
- }
1097
-
1098
- export const markLog = (logs: string[]): number => logs.join("").length;
1099
-
1100
837
  export async function waitForLogSince(
1101
838
  logs: string[],
1102
839
  mark: number,