@akanjs/devkit 3.0.0-beta.9 → 3.0.0

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 (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +21 -7
  3. package/README.md +17 -8
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +439 -141
  19. package/akanConfig/akanConfig.ts +359 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +22 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +182 -0
  241. package/mobile/desktopBin.ts +144 -0
  242. package/mobile/desktopServerStage.test.ts +252 -0
  243. package/mobile/desktopServerStage.ts +178 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +184 -0
  250. package/mobile/nativeApp.ts +454 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +438 -0
  254. package/mobile/nativeConfig.ts +294 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +9 -25
  298. package/subspace.ts +48 -115
  299. package/subspaceConfig.ts +3 -11
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. 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,11 @@ 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",
357
272
  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.
273
+ // Pinned: `getDevPort()` derives from the fixture's index in `apps/`, which parallel runs keep moving.
364
274
  AKAN_DEV_PORT: String(port),
365
275
  ...env,
366
276
  },
@@ -387,19 +297,15 @@ export const dictionary = serviceDictionary(["en", "ko"])
387
297
  const host: DevStabilityHost = {
388
298
  proc,
389
299
  logs,
390
- markLog: () => markLog(logs),
300
+ markLog: () => logs.join("").length,
391
301
  waitForLog: (pattern, waitMs) =>
392
- DevStabilityHarness.#timed(`waitForLog ${pattern}`, () => waitForLog(logs, pattern, waitMs)),
302
+ DevStabilityHarness.#timed(`waitForLog ${pattern}`, () => waitForLogSince(logs, 0, pattern, waitMs)),
393
303
  waitForLogSince: (mark, pattern, waitMs) =>
394
304
  DevStabilityHarness.#timed(`waitForLogSince ${pattern}`, () => waitForLogSince(logs, mark, pattern, waitMs)),
395
305
  stop: async () => {
396
306
  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.
307
+ // Under `bash -lc`, killing `proc` only kills the shell, so every descendant is signalled too; `proc`
308
+ // itself is signalled directly because `descendantPids` is empty when `ps` times out.
403
309
  const pids = await DevStabilityHarness.#watched("descendants", () =>
404
310
  DevStabilityHarness.descendantPids(proc.pid),
405
311
  );
@@ -414,8 +320,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
414
320
  DevStabilityHarness.#signalPids(survivors, "SIGKILL");
415
321
  DevStabilityHarness.#signalPids(pids, "SIGKILL");
416
322
  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.
323
+ // Signalling is not reaping: a live build worker would keep writing into the tree the caller deletes next.
419
324
  const signalled = [...new Set([...pids, ...survivors])];
420
325
  const gone = await DevStabilityHarness.#watched("await gone", () =>
421
326
  DevStabilityHarness.#waitForPidsGone(signalled, 15_000),
@@ -439,13 +344,6 @@ export const dictionary = serviceDictionary(["en", "ko"])
439
344
  this.#host = null;
440
345
  }
441
346
 
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
347
  static async #dumpLogs(appName: string, logs: string[]): Promise<void> {
450
348
  const dir = process.env.AKAN_DEV_STABILITY_LOG_DIR;
451
349
  if (!dir) return;
@@ -459,49 +357,22 @@ export const dictionary = serviceDictionary(["en", "ko"])
459
357
  await writeFile(target, contents);
460
358
  }
461
359
 
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
- */
360
+ // Bun's recursive `fs.watch` can drop an edit landing in a build's write burst, so re-apply it until the dev
361
+ // server logs evidence of seeing it (a regression detector: `retried` should stay 0); callers assert outcomes.
481
362
  async editUntilSeen(
482
363
  host: DevStabilityHost,
483
364
  mutate: (attempt: number) => Promise<void>,
484
365
  {
485
366
  evidence = DevStabilityHarness.#editEvidence,
486
367
  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).
368
+ // Generous: a dropped event never arrives late, while a needless retry can cost a whole dev-host restart.
491
369
  evidenceTimeoutMs = 20_000,
492
- settleMs = 0,
493
- retryDelayMs = 750,
494
370
  }: {
495
371
  evidence?: RegExp;
496
372
  attempts?: number;
497
373
  evidenceTimeoutMs?: number;
498
- settleMs?: number;
499
- retryDelayMs?: number;
500
374
  } = {},
501
375
  ): 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
376
  for (let attempt = 1; ; attempt++) {
506
377
  const mark = host.markLog();
507
378
  await mutate(attempt);
@@ -515,12 +386,10 @@ export const dictionary = serviceDictionary(["en", "ko"])
515
386
  throw new Error(
516
387
  `Dev server never reacted to ${attempts} edit(s) of ${this.appName}: waited ${evidenceTimeoutMs}ms each for ${evidence}`,
517
388
  );
518
- await wait(retryDelayMs);
389
+ await wait(750);
519
390
  }
520
391
  }
521
392
 
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
393
  static readonly #editEvidence = /\[dev-plan\] generation=\d+|\[idle-suspend\] waking/;
525
394
  static #observedEdits = 0;
526
395
  static #retriedEdits = 0;
@@ -531,15 +400,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
531
400
 
532
401
  static readonly #waitDurations: { label: string; ms: number }[] = [];
533
402
 
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
- */
403
+ // Bounded waits can still sum past the test timeout, and Bun then names no step, so each one is recorded.
543
404
  static async #timed<T>(label: string, work: () => Promise<T>): Promise<T> {
544
405
  const at = performance.now();
545
406
  try {
@@ -549,7 +410,6 @@ export const dictionary = serviceDictionary(["en", "ko"])
549
410
  }
550
411
  }
551
412
 
552
- /** The slowest waits observed, worst first, for an end-of-run report. */
553
413
  static waitStats(limit = 5): { label: string; ms: number }[] {
554
414
  return [...DevStabilityHarness.#waitDurations].sort((a, b) => b.ms - a.ms).slice(0, limit);
555
415
  }
@@ -568,8 +428,6 @@ export const dictionary = serviceDictionary(["en", "ko"])
568
428
  const body = await this.tryWaitForHttpText(text, timeoutMs);
569
429
  if (body) return body;
570
430
  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
431
  const tail = (this.#host?.logs.join("") ?? "").slice(-2_000);
574
432
  throw new Error(
575
433
  `Timed out waiting for HTTP text ${String(text)} after ${timeoutMs}ms on port ${port}; ` +
@@ -587,14 +445,9 @@ export const dictionary = serviceDictionary(["en", "ko"])
587
445
  async #pollHttpText(text: string | RegExp, timeoutMs: number): Promise<string | null> {
588
446
  const started = Date.now();
589
447
  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.
448
+ // Re-resolved each poll: a dev-host restart can bind a different port.
593
449
  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.
450
+ // Bounded per request: a recovering dev server can accept a connection and never answer.
598
451
  const budget = Math.max(250, Math.min(5_000, timeoutMs - (Date.now() - started)));
599
452
  const body = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(budget) })
600
453
  .then((res) => res.text())
@@ -632,15 +485,8 @@ export const dictionary = serviceDictionary(["en", "ko"])
632
485
  throw new Error("Timed out connecting HMR websocket");
633
486
  }
634
487
 
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
- */
488
+ // Reconnects like the real client (`akanjs/server/hmr/clientScript.ts`): a socket a backend restart dropped
489
+ // would otherwise read as silence.
644
490
  async connectHmrProbe(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<DevStabilityHmrProbe> {
645
491
  const messages: unknown[] = [];
646
492
  let socket = await this.connectHmr(timeoutMs);
@@ -656,9 +502,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
656
502
  }
657
503
  });
658
504
  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.
505
+ // Bounded and short: the last close is the teardown in `cleanup()`, where retrying would poll a dead port.
662
506
  if (closedByCaller || reconnects >= DevStabilityHarness.#maxProbeReconnects) return;
663
507
  void this.connectHmr(Math.min(timeoutMs, 5_000))
664
508
  .then((next) => {
@@ -670,16 +514,12 @@ export const dictionary = serviceDictionary(["en", "ko"])
670
514
  socket = next;
671
515
  listen(next);
672
516
  })
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.
517
+ // A server gone for good surfaces through the caller's own waits; never throw from a listener.
675
518
  .catch(() => undefined);
676
519
  });
677
520
  };
678
521
  listen(socket);
679
522
  return {
680
- get ws() {
681
- return socket;
682
- },
683
523
  get reconnects() {
684
524
  return reconnects;
685
525
  },
@@ -716,60 +556,11 @@ export const dictionary = serviceDictionary(["en", "ko"])
716
556
  }
717
557
  }
718
558
 
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
559
  async resolvePort(): Promise<number> {
760
560
  return this.#gatewayPortFromLogs() ?? this.#boundPort ?? (await this.#allocatePort()).port;
761
561
  }
762
562
 
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
- */
563
+ // The latest match: a restarted dev host recomputes its port from the `apps/` listing, which may have moved.
773
564
  #gatewayPortFromLogs(): number | null {
774
565
  const logs = this.#host?.logs;
775
566
  if (!logs) return null;
@@ -779,14 +570,11 @@ export const dictionary = serviceDictionary(["en", "ko"])
779
570
  return latest;
780
571
  }
781
572
 
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. */
573
+ // A pid-seeded, forward-only cursor probed against the OS: no two harnesses in a process share an offset while
574
+ // one still shuts down, and concurrent runs start in different places.
786
575
  static readonly portOffsetMin = 3_000;
787
576
  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. */
577
+ // The port is `8282 + appIndex + offset`: a stride wider than any index drift keeps neighbouring offsets apart.
790
578
  static readonly portOffsetStride = 4;
791
579
  static #portOffsetCursor: number | null = null;
792
580
 
@@ -807,8 +595,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
807
595
  );
808
596
  }
809
597
 
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. */
598
+ // The default interface is the strict test: a bind there fails if any address the app might pick is held.
812
599
  static async isPortFree(port: number): Promise<boolean> {
813
600
  try {
814
601
  Bun.serve({ port, fetch: () => new Response("probe") }).stop(true);
@@ -831,8 +618,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
831
618
  for (let attempt = 0; attempt < DevStabilityHarness.#portOffsetSlots; attempt++) {
832
619
  const offset = DevStabilityHarness.#nextPortOffset();
833
620
  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.
621
+ // Child 0's websocket port is `port + 10_000` (`AkanApp` `#wsBasePort`), so both must be free.
836
622
  if ((await DevStabilityHarness.isPortFree(port)) && (await DevStabilityHarness.isPortFree(port + 10_000)))
837
623
  return { port, offset };
838
624
  }
@@ -841,8 +627,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
841
627
  );
842
628
  }
843
629
 
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. */
630
+ // Mirrors `getDevPort()`: dirs holding an akan.config.ts, locale-sorted; a stray entry would shift the port.
846
631
  async #appIndex(): Promise<number> {
847
632
  const appsDir = path.join(this.workspaceRoot, "apps");
848
633
  const entries = await readdir(appsDir, { withFileTypes: true }).catch(() => []);
@@ -859,7 +644,6 @@ export const dictionary = serviceDictionary(["en", "ko"])
859
644
  return Math.max(apps.indexOf(this.appName), 0);
860
645
  }
861
646
 
862
- /** Stop predicting once the gateway has said what it bound. */
863
647
  async #adoptBoundPort(host: DevStabilityHost): Promise<void> {
864
648
  const match = await host
865
649
  .waitForLog(/AkanApp gateway is running on port http:\/\/localhost:(\d+)/, 30_000)
@@ -872,13 +656,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
872
656
  console.warn(`[harness] ${this.appName} bound port ${bound}, not the predicted ${predicted}`);
873
657
  }
874
658
 
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
- */
659
+ // Only fixtures whose owning test pid is gone, so a concurrent run's live fixtures are never touched.
882
660
  static async sweepAbandonedFixtures(workspaceRoot: string): Promise<string[]> {
883
661
  const appsDir = path.join(workspaceRoot, "apps");
884
662
  const entries = await readdir(appsDir, { withFileTypes: true }).catch(() => []);
@@ -908,15 +686,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
908
686
  }
909
687
  }
910
688
 
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.
689
+ // Not `incrementalBuilder`: the disposable worker `incrementalBuilder/buildBatch.proc.ts` would match too.
920
690
  static readonly #builderCmd = "incrementalBuilder.proc";
921
691
  static readonly #buildWorkerCmd = "buildBatch.proc";
922
692
 
@@ -936,15 +706,10 @@ export const dictionary = serviceDictionary(["en", "ko"])
936
706
  );
937
707
  }
938
708
 
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
709
  static async builderProcess(rootPid: number): Promise<{ pid: number; rssBytes: number } | null> {
944
710
  return DevStabilityHarness.#findProcess(rootPid, DevStabilityHarness.#builderCmd);
945
711
  }
946
712
 
947
- /** The disposable per-generation build worker, which should only exist while a build is running. */
948
713
  static async buildWorkerProcess(rootPid: number): Promise<{ pid: number; rssBytes: number } | null> {
949
714
  return DevStabilityHarness.#findProcess(rootPid, DevStabilityHarness.#buildWorkerCmd);
950
715
  }
@@ -956,26 +721,15 @@ export const dictionary = serviceDictionary(["en", "ko"])
956
721
  return found ? { pid: found.pid, rssBytes: found.rssKb * 1024 } : null;
957
722
  }
958
723
 
959
- /** Pids of `rootPid` and everything under it, deepest first, so callers can signal children before parents. */
724
+ // Deepest first, so callers signal children before parents.
960
725
  static async descendantPids(rootPid: number | undefined): Promise<number[]> {
961
726
  if (!rootPid) return [];
962
727
  const pids = DevStabilityHarness.#collectDescendants(await DevStabilityHarness.#psRowsOrEmpty(), rootPid);
963
728
  return [...pids].reverse();
964
729
  }
965
730
 
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
- */
731
+ // `ps` hangs under parallel load, so it is bounded and spawned directly (a hung `Bun.$` shell leaks). `null`, not
732
+ // `[]`, means "could not look", which a measurement must never read as "nothing running".
979
733
  static readonly #psTimeoutMs = 5_000;
980
734
 
981
735
  static async #psRows(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }> | null> {
@@ -987,8 +741,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
987
741
  stdin: "ignore",
988
742
  });
989
743
  } 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".
744
+ // Slim images such as `oven/bun` ship without procps.
992
745
  console.warn(`[harness] could not run ps: ${error instanceof Error ? error.message : String(error)}`);
993
746
  return null;
994
747
  }
@@ -1012,13 +765,10 @@ export const dictionary = serviceDictionary(["en", "ko"])
1012
765
  });
1013
766
  }
1014
767
 
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
768
  static async #psRowsOrEmpty(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }>> {
1018
769
  return (await DevStabilityHarness.#psRows()) ?? [];
1019
770
  }
1020
771
 
1021
- /** For callers that measure: a missing process list must fail loudly, never read as "nothing running". */
1022
772
  static async #psRowsOrThrow(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }>> {
1023
773
  const rows = (await DevStabilityHarness.#psRows()) ?? (await DevStabilityHarness.#psRows());
1024
774
  if (!rows) throw new Error("ps did not answer twice in a row; cannot measure the process tree");
@@ -1026,10 +776,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
1026
776
  }
1027
777
 
1028
778
  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()`.
779
+ // To a fixpoint, not a fixed depth: the tree is seven levels deep and `ps` lists it in no particular order.
1033
780
  const pids = new Set([rootPid]);
1034
781
  for (let added = 1; added > 0; ) {
1035
782
  added = 0;
@@ -1066,8 +813,6 @@ export async function waitForHmrMessageSince(
1066
813
  if (found) return found;
1067
814
  await wait(50);
1068
815
  }
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
816
  throw new Error(
1072
817
  `Timed out waiting for HMR message since mark ${mark}${describeProbe ? ` (${describeProbe()})` : ""}`,
1073
818
  );
@@ -1087,16 +832,6 @@ export async function waitForNoHmrMessageSince(
1087
832
  }
1088
833
  }
1089
834
 
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
835
  export async function waitForLogSince(
1101
836
  logs: string[],
1102
837
  mark: number,