@xincli/opentui-core 0.4.6

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 (476) hide show
  1. package/README.md +63 -0
  2. package/dev/keypress-debug-renderer.ts +148 -0
  3. package/dev/keypress-debug.ts +43 -0
  4. package/dev/print-env-vars.ts +32 -0
  5. package/dev/test-tmux-graphics-334.sh +68 -0
  6. package/dev/test-tmux-theme-queries.sh +151 -0
  7. package/dev/thai-debug-test.ts +68 -0
  8. package/dev/theme-mode.ts +290 -0
  9. package/docs/development.md +144 -0
  10. package/package.json +102 -0
  11. package/prebuilt/aarch64-android/libopentui.so +0 -0
  12. package/scripts/build-android.ts +226 -0
  13. package/scripts/build-native-termux.sh +525 -0
  14. package/scripts/build.ts +530 -0
  15. package/scripts/dist-test.ts +312 -0
  16. package/scripts/package-prebuilt.ts +110 -0
  17. package/scripts/publish.ts +60 -0
  18. package/scripts/test-node-hook.mjs +16 -0
  19. package/scripts/test-node.ts +241 -0
  20. package/scripts/vendor-deps.sh +73 -0
  21. package/src/NativeSpanFeed.ts +375 -0
  22. package/src/Renderable.ts +1878 -0
  23. package/src/__snapshots__/buffer.test.ts.snap +28 -0
  24. package/src/animation/Timeline.test.ts +2709 -0
  25. package/src/animation/Timeline.ts +598 -0
  26. package/src/ansi.ts +22 -0
  27. package/src/audio.ts +479 -0
  28. package/src/benchmark/attenuation-benchmark.ts +81 -0
  29. package/src/benchmark/audio-playback-benchmark.ts +344 -0
  30. package/src/benchmark/box-draw-benchmark.ts +1042 -0
  31. package/src/benchmark/colormatrix-benchmark.ts +123 -0
  32. package/src/benchmark/gain-benchmark.ts +80 -0
  33. package/src/benchmark/latest-all-bench-run.json +707 -0
  34. package/src/benchmark/latest-async-bench-run.json +336 -0
  35. package/src/benchmark/latest-default-bench-run.json +657 -0
  36. package/src/benchmark/latest-large-bench-run.json +707 -0
  37. package/src/benchmark/latest-quick-bench-run.json +207 -0
  38. package/src/benchmark/layout-benchmark.ts +2547 -0
  39. package/src/benchmark/markdown-benchmark.ts +1796 -0
  40. package/src/benchmark/native-span-feed-async-benchmark.ts +355 -0
  41. package/src/benchmark/native-span-feed-benchmark.md +56 -0
  42. package/src/benchmark/native-span-feed-benchmark.ts +596 -0
  43. package/src/benchmark/native-span-feed-compare.ts +280 -0
  44. package/src/benchmark/render-traversal-benchmark.ts +928 -0
  45. package/src/benchmark/text-buffer-render-benchmark.ts +874 -0
  46. package/src/benchmark/text-table-benchmark.ts +948 -0
  47. package/src/buffer.test.ts +292 -0
  48. package/src/buffer.ts +564 -0
  49. package/src/console.test.ts +629 -0
  50. package/src/console.ts +1253 -0
  51. package/src/edit-buffer.test.ts +1852 -0
  52. package/src/edit-buffer.ts +417 -0
  53. package/src/editor-view.test.ts +1056 -0
  54. package/src/editor-view.ts +290 -0
  55. package/src/index.ts +25 -0
  56. package/src/lib/KeyHandler.integration.test.ts +292 -0
  57. package/src/lib/KeyHandler.stopPropagation.test.ts +289 -0
  58. package/src/lib/KeyHandler.test.ts +662 -0
  59. package/src/lib/KeyHandler.ts +222 -0
  60. package/src/lib/RGBA.test.ts +60 -0
  61. package/src/lib/RGBA.ts +343 -0
  62. package/src/lib/ascii.font.ts +330 -0
  63. package/src/lib/border.test.ts +83 -0
  64. package/src/lib/border.ts +170 -0
  65. package/src/lib/bunfs.test.ts +27 -0
  66. package/src/lib/bunfs.ts +18 -0
  67. package/src/lib/clipboard.test.ts +63 -0
  68. package/src/lib/clipboard.ts +41 -0
  69. package/src/lib/clock.ts +35 -0
  70. package/src/lib/data-paths.test.ts +133 -0
  71. package/src/lib/data-paths.ts +109 -0
  72. package/src/lib/debounce.ts +106 -0
  73. package/src/lib/detect-links.test.ts +98 -0
  74. package/src/lib/detect-links.ts +56 -0
  75. package/src/lib/env.test.ts +228 -0
  76. package/src/lib/env.ts +209 -0
  77. package/src/lib/extmarks-history.ts +51 -0
  78. package/src/lib/extmarks-multiwidth.test.ts +324 -0
  79. package/src/lib/extmarks.test.ts +3547 -0
  80. package/src/lib/extmarks.ts +861 -0
  81. package/src/lib/fonts/block.json +405 -0
  82. package/src/lib/fonts/grid.json +265 -0
  83. package/src/lib/fonts/huge.json +741 -0
  84. package/src/lib/fonts/pallet.json +314 -0
  85. package/src/lib/fonts/shade.json +591 -0
  86. package/src/lib/fonts/slick.json +321 -0
  87. package/src/lib/fonts/tiny.json +69 -0
  88. package/src/lib/hast-styled-text.ts +59 -0
  89. package/src/lib/index.ts +22 -0
  90. package/src/lib/keybinding.internal.test.ts +342 -0
  91. package/src/lib/keybinding.internal.ts +182 -0
  92. package/src/lib/objects-in-viewport.test.ts +789 -0
  93. package/src/lib/objects-in-viewport.ts +153 -0
  94. package/src/lib/output.capture.ts +58 -0
  95. package/src/lib/parse.keypress-kitty.protocol.test.ts +347 -0
  96. package/src/lib/parse.keypress-kitty.test.ts +712 -0
  97. package/src/lib/parse.keypress-kitty.ts +470 -0
  98. package/src/lib/parse.keypress.test.ts +2020 -0
  99. package/src/lib/parse.keypress.ts +480 -0
  100. package/src/lib/parse.mouse.test.ts +552 -0
  101. package/src/lib/parse.mouse.ts +232 -0
  102. package/src/lib/paste.ts +18 -0
  103. package/src/lib/queue.ts +65 -0
  104. package/src/lib/render-geometry.ts +36 -0
  105. package/src/lib/renderable.validations.test.ts +87 -0
  106. package/src/lib/renderable.validations.ts +83 -0
  107. package/src/lib/scroll-acceleration.ts +98 -0
  108. package/src/lib/selection.ts +261 -0
  109. package/src/lib/singleton.ts +34 -0
  110. package/src/lib/stdin-parser.test.ts +2439 -0
  111. package/src/lib/stdin-parser.ts +2007 -0
  112. package/src/lib/styled-text.ts +178 -0
  113. package/src/lib/terminal-capability-detection.test.ts +226 -0
  114. package/src/lib/terminal-capability-detection.ts +98 -0
  115. package/src/lib/terminal-palette.test.ts +954 -0
  116. package/src/lib/terminal-palette.ts +441 -0
  117. package/src/lib/tree-sitter/assets/README.md +119 -0
  118. package/src/lib/tree-sitter/assets/javascript/highlights.scm +205 -0
  119. package/src/lib/tree-sitter/assets/javascript/tree-sitter-javascript.wasm +0 -0
  120. package/src/lib/tree-sitter/assets/markdown/highlights.scm +150 -0
  121. package/src/lib/tree-sitter/assets/markdown/injections.scm +27 -0
  122. package/src/lib/tree-sitter/assets/markdown/tree-sitter-markdown.wasm +0 -0
  123. package/src/lib/tree-sitter/assets/markdown_inline/highlights.scm +115 -0
  124. package/src/lib/tree-sitter/assets/markdown_inline/tree-sitter-markdown_inline.wasm +0 -0
  125. package/src/lib/tree-sitter/assets/typescript/highlights.scm +604 -0
  126. package/src/lib/tree-sitter/assets/typescript/tree-sitter-typescript.wasm +0 -0
  127. package/src/lib/tree-sitter/assets/update.ts +360 -0
  128. package/src/lib/tree-sitter/assets/zig/highlights.scm +284 -0
  129. package/src/lib/tree-sitter/assets/zig/tree-sitter-zig.wasm +0 -0
  130. package/src/lib/tree-sitter/assets.d.ts +9 -0
  131. package/src/lib/tree-sitter/cache.test.ts +291 -0
  132. package/src/lib/tree-sitter/client.test.ts +1434 -0
  133. package/src/lib/tree-sitter/client.ts +847 -0
  134. package/src/lib/tree-sitter/default-parsers.ts +133 -0
  135. package/src/lib/tree-sitter/download-utils.ts +148 -0
  136. package/src/lib/tree-sitter/index.ts +75 -0
  137. package/src/lib/tree-sitter/parser.worker.ts +1098 -0
  138. package/src/lib/tree-sitter/parsers-config.ts +81 -0
  139. package/src/lib/tree-sitter/resolve-ft.test.ts +55 -0
  140. package/src/lib/tree-sitter/resolve-ft.ts +189 -0
  141. package/src/lib/tree-sitter/types.ts +132 -0
  142. package/src/lib/tree-sitter/update-assets.ts +10 -0
  143. package/src/lib/tree-sitter-styled-text.test.ts +1253 -0
  144. package/src/lib/tree-sitter-styled-text.ts +314 -0
  145. package/src/lib/validate-dir-name.ts +55 -0
  146. package/src/lib/yoga.options.test.ts +628 -0
  147. package/src/lib/yoga.options.ts +346 -0
  148. package/src/native-event-worker-repro.test.ts +48 -0
  149. package/src/native-event-worker-repro.worker.ts +9 -0
  150. package/src/native-handle.test.ts +221 -0
  151. package/src/platform/ffi.test.ts +516 -0
  152. package/src/platform/ffi.ts +653 -0
  153. package/src/platform/runtime.test.ts +52 -0
  154. package/src/platform/runtime.ts +121 -0
  155. package/src/platform/test.ts +45 -0
  156. package/src/platform/worker-handler-cleanup.fixture.ts +12 -0
  157. package/src/platform/worker-onmessage-startup.fixture.ts +10 -0
  158. package/src/platform/worker-startup.fixture.ts +9 -0
  159. package/src/platform/worker.node-test.ts +153 -0
  160. package/src/platform/worker.ts +425 -0
  161. package/src/plugins/core-slot.ts +579 -0
  162. package/src/plugins/registry.ts +402 -0
  163. package/src/plugins/types.ts +46 -0
  164. package/src/post/effects.ts +948 -0
  165. package/src/post/filters.ts +512 -0
  166. package/src/post/matrices.ts +288 -0
  167. package/src/renderables/ASCIIFont.ts +219 -0
  168. package/src/renderables/Box.test.ts +474 -0
  169. package/src/renderables/Box.ts +353 -0
  170. package/src/renderables/Code.test.ts +2388 -0
  171. package/src/renderables/Code.ts +555 -0
  172. package/src/renderables/Diff.regression.test.ts +381 -0
  173. package/src/renderables/Diff.test.ts +3212 -0
  174. package/src/renderables/Diff.ts +1278 -0
  175. package/src/renderables/EditBufferRenderable.test.ts +288 -0
  176. package/src/renderables/EditBufferRenderable.ts +1152 -0
  177. package/src/renderables/FrameBuffer.ts +47 -0
  178. package/src/renderables/Input.test.ts +1310 -0
  179. package/src/renderables/Input.ts +273 -0
  180. package/src/renderables/LineNumberRenderable.ts +725 -0
  181. package/src/renderables/Markdown.ts +2144 -0
  182. package/src/renderables/ScrollBar.ts +423 -0
  183. package/src/renderables/ScrollBox.ts +903 -0
  184. package/src/renderables/Select.test.ts +1134 -0
  185. package/src/renderables/Select.ts +535 -0
  186. package/src/renderables/Slider.test.ts +456 -0
  187. package/src/renderables/Slider.ts +342 -0
  188. package/src/renderables/TabSelect.test.ts +238 -0
  189. package/src/renderables/TabSelect.ts +447 -0
  190. package/src/renderables/Text.selection-buffer.test.ts +118 -0
  191. package/src/renderables/Text.test.ts +2662 -0
  192. package/src/renderables/Text.ts +147 -0
  193. package/src/renderables/TextBufferRenderable.ts +504 -0
  194. package/src/renderables/TextNode.test.ts +1137 -0
  195. package/src/renderables/TextNode.ts +379 -0
  196. package/src/renderables/TextTable.test.ts +1411 -0
  197. package/src/renderables/TextTable.ts +1434 -0
  198. package/src/renderables/Textarea.ts +425 -0
  199. package/src/renderables/TimeToFirstDraw.ts +89 -0
  200. package/src/renderables/__snapshots__/Code.test.ts.snap +13 -0
  201. package/src/renderables/__snapshots__/Diff.test.ts.snap +785 -0
  202. package/src/renderables/__snapshots__/Text.test.ts.snap +421 -0
  203. package/src/renderables/__snapshots__/TextTable.test.ts.snap +215 -0
  204. package/src/renderables/__tests__/Code.test.ts +55 -0
  205. package/src/renderables/__tests__/LineNumberRenderable.scrollbox-simple.test.ts +144 -0
  206. package/src/renderables/__tests__/LineNumberRenderable.scrollbox.test.ts +827 -0
  207. package/src/renderables/__tests__/LineNumberRenderable.test.ts +1906 -0
  208. package/src/renderables/__tests__/LineNumberRenderable.wrapping.test.ts +85 -0
  209. package/src/renderables/__tests__/Markdown.code-colors.test.ts +288 -0
  210. package/src/renderables/__tests__/Markdown.test.ts +4175 -0
  211. package/src/renderables/__tests__/MultiRenderable.selection.test.ts +87 -0
  212. package/src/renderables/__tests__/Textarea.buffer.test.ts +683 -0
  213. package/src/renderables/__tests__/Textarea.destroyed-events.test.ts +675 -0
  214. package/src/renderables/__tests__/Textarea.editing.test.ts +2120 -0
  215. package/src/renderables/__tests__/Textarea.error-handling.test.ts +35 -0
  216. package/src/renderables/__tests__/Textarea.events.test.ts +738 -0
  217. package/src/renderables/__tests__/Textarea.highlights.test.ts +590 -0
  218. package/src/renderables/__tests__/Textarea.keybinding.test.ts +3275 -0
  219. package/src/renderables/__tests__/Textarea.paste.test.ts +357 -0
  220. package/src/renderables/__tests__/Textarea.rendering.test.ts +1927 -0
  221. package/src/renderables/__tests__/Textarea.scroll.test.ts +733 -0
  222. package/src/renderables/__tests__/Textarea.selection.test.ts +1589 -0
  223. package/src/renderables/__tests__/Textarea.stress.test.ts +670 -0
  224. package/src/renderables/__tests__/Textarea.undo-redo.test.ts +383 -0
  225. package/src/renderables/__tests__/Textarea.visual-lines.test.ts +310 -0
  226. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.code.test.ts.snap +221 -0
  227. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox-simple.test.ts.snap +89 -0
  228. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox.test.ts.snap +457 -0
  229. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.test.ts.snap +158 -0
  230. package/src/renderables/__tests__/__snapshots__/Textarea.rendering.test.ts.snap +387 -0
  231. package/src/renderables/__tests__/markdown-parser.test.ts +241 -0
  232. package/src/renderables/__tests__/renderable-test-utils.ts +60 -0
  233. package/src/renderables/composition/README.md +8 -0
  234. package/src/renderables/composition/VRenderable.ts +32 -0
  235. package/src/renderables/composition/constructs.ts +127 -0
  236. package/src/renderables/composition/vnode.ts +277 -0
  237. package/src/renderables/index.ts +23 -0
  238. package/src/renderables/markdown-parser.ts +79 -0
  239. package/src/renderer-theme-mode.ts +199 -0
  240. package/src/renderer.ts +4975 -0
  241. package/src/runtime-plugin-support-configure.ts +63 -0
  242. package/src/runtime-plugin-support.ts +13 -0
  243. package/src/runtime-plugin.ts +626 -0
  244. package/src/specs/terminal-startup.md +64 -0
  245. package/src/syntax-style.test.ts +841 -0
  246. package/src/syntax-style.ts +274 -0
  247. package/src/testing/README.md +219 -0
  248. package/src/testing/bun-test-node.ts +880 -0
  249. package/src/testing/capture-spans.test.ts +216 -0
  250. package/src/testing/integration.test.ts +276 -0
  251. package/src/testing/manual-clock.ts +117 -0
  252. package/src/testing/mock-keys.test.ts +1378 -0
  253. package/src/testing/mock-keys.ts +457 -0
  254. package/src/testing/mock-mouse.test.ts +218 -0
  255. package/src/testing/mock-mouse.ts +247 -0
  256. package/src/testing/mock-tree-sitter-client.ts +81 -0
  257. package/src/testing/spy.ts +13 -0
  258. package/src/testing/terminal-capabilities.ts +47 -0
  259. package/src/testing/test-recorder.test.ts +410 -0
  260. package/src/testing/test-recorder.ts +133 -0
  261. package/src/testing/test-renderer.ts +375 -0
  262. package/src/testing/test-renderer.wait.test.ts +278 -0
  263. package/src/testing/test-streams.ts +31 -0
  264. package/src/testing.ts +9 -0
  265. package/src/tests/__snapshots__/absolute-positioning.snapshot.test.ts.snap +481 -0
  266. package/src/tests/__snapshots__/renderable.snapshot.test.ts.snap +19 -0
  267. package/src/tests/__snapshots__/scrollbox.test.ts.snap +29 -0
  268. package/src/tests/absolute-positioning.snapshot.test.ts +638 -0
  269. package/src/tests/allocator-stats.test.ts +53 -0
  270. package/src/tests/audio.test.ts +303 -0
  271. package/src/tests/destroy-during-render.test.ts +200 -0
  272. package/src/tests/destroy-on-exit.fixture.ts +36 -0
  273. package/src/tests/destroy-on-exit.test.ts +62 -0
  274. package/src/tests/ffi-borrowed-pointer-callsites.test.ts +153 -0
  275. package/src/tests/hover-cursor.test.ts +98 -0
  276. package/src/tests/native-backed-measurement-lifecycle.test.ts +133 -0
  277. package/src/tests/native-backed-measurement-parity.test.ts +322 -0
  278. package/src/tests/native-span-feed-async.test.ts +173 -0
  279. package/src/tests/native-span-feed-close.test.ts +120 -0
  280. package/src/tests/native-span-feed-coverage.test.ts +227 -0
  281. package/src/tests/native-span-feed-edge-cases.test.ts +384 -0
  282. package/src/tests/native-span-feed-use-after-free.test.ts +45 -0
  283. package/src/tests/opacity.test.ts +123 -0
  284. package/src/tests/renderable.snapshot.test.ts +524 -0
  285. package/src/tests/renderable.test.ts +1851 -0
  286. package/src/tests/renderer.clock.test.ts +270 -0
  287. package/src/tests/renderer.console-startup.test.ts +2151 -0
  288. package/src/tests/renderer.control.test.ts +458 -0
  289. package/src/tests/renderer.core-slot-binding.test.ts +962 -0
  290. package/src/tests/renderer.cursor.test.ts +26 -0
  291. package/src/tests/renderer.custom-stdout.test.ts +1112 -0
  292. package/src/tests/renderer.destroy-during-render.test.ts +147 -0
  293. package/src/tests/renderer.focus-restore.test.ts +257 -0
  294. package/src/tests/renderer.focus.test.ts +329 -0
  295. package/src/tests/renderer.idle.test.ts +219 -0
  296. package/src/tests/renderer.input.test.ts +2615 -0
  297. package/src/tests/renderer.kitty-flags.test.ts +195 -0
  298. package/src/tests/renderer.lifecycle.test.ts +34 -0
  299. package/src/tests/renderer.mouse.test.ts +1315 -0
  300. package/src/tests/renderer.notifications.test.ts +27 -0
  301. package/src/tests/renderer.palette.test.ts +1199 -0
  302. package/src/tests/renderer.remote.test.ts +247 -0
  303. package/src/tests/renderer.render-stats.test.ts +146 -0
  304. package/src/tests/renderer.scrollback-surface.test.ts +474 -0
  305. package/src/tests/renderer.selection.test.ts +138 -0
  306. package/src/tests/renderer.slot-registry.test.ts +684 -0
  307. package/src/tests/renderer.tracker.test.ts +202 -0
  308. package/src/tests/renderer.useMouse.test.ts +83 -0
  309. package/src/tests/runtime-plugin-node-modules-cycle.fixture.ts +76 -0
  310. package/src/tests/runtime-plugin-node-modules-import-like-string.fixture.ts +48 -0
  311. package/src/tests/runtime-plugin-node-modules-mjs.fixture.ts +43 -0
  312. package/src/tests/runtime-plugin-node-modules-no-bare-rewrite.fixture.ts +67 -0
  313. package/src/tests/runtime-plugin-node-modules-package-type-cache.fixture.ts +72 -0
  314. package/src/tests/runtime-plugin-node-modules-runtime-specifier.fixture.ts +44 -0
  315. package/src/tests/runtime-plugin-node-modules-scoped-package-bare-rewrite.fixture.ts +85 -0
  316. package/src/tests/runtime-plugin-path-alias.fixture.ts +43 -0
  317. package/src/tests/runtime-plugin-resolve-roots.fixture.ts +65 -0
  318. package/src/tests/runtime-plugin-support-configure.fixture.ts +31 -0
  319. package/src/tests/runtime-plugin-support-late-addition.fixture.ts +17 -0
  320. package/src/tests/runtime-plugin-support.fixture.ts +11 -0
  321. package/src/tests/runtime-plugin-support.test.ts +54 -0
  322. package/src/tests/runtime-plugin-windows-file-url.fixture.ts +30 -0
  323. package/src/tests/runtime-plugin.fixture.ts +40 -0
  324. package/src/tests/runtime-plugin.test.ts +369 -0
  325. package/src/tests/scrollbox-culling-bug.test.ts +114 -0
  326. package/src/tests/scrollbox-hitgrid-resize.test.ts +136 -0
  327. package/src/tests/scrollbox-hitgrid.test.ts +909 -0
  328. package/src/tests/scrollbox.test.ts +1895 -0
  329. package/src/tests/wrap-resize-perf.test.ts +276 -0
  330. package/src/tests/yoga-api-coverage.test.ts +409 -0
  331. package/src/tests/yoga-callback-stress.test.ts +122 -0
  332. package/src/tests/yoga-ffi.test.ts +117 -0
  333. package/src/tests/yoga-setters.test.ts +921 -0
  334. package/src/tests/yoga-upstream/YGAlignBaselineTest.test.ts +124 -0
  335. package/src/tests/yoga-upstream/YGComputedBorderTest.test.ts +27 -0
  336. package/src/tests/yoga-upstream/YGComputedMarginTest.test.ts +27 -0
  337. package/src/tests/yoga-upstream/YGComputedPaddingTest.test.ts +27 -0
  338. package/src/tests/yoga-upstream/YGDirtiedTest.test.ts +157 -0
  339. package/src/tests/yoga-upstream/YGErrataTest.test.ts +40 -0
  340. package/src/tests/yoga-upstream/YGFlexBasisAuto.test.ts +23 -0
  341. package/src/tests/yoga-upstream/YGHasNewLayout.test.ts +81 -0
  342. package/src/tests/yoga-upstream/YGMeasureCacheTest.test.ts +31 -0
  343. package/src/tests/yoga-upstream/YGMeasureTest.test.ts +70 -0
  344. package/src/tests/yoga-upstream/generated/YGAbsolutePositionTest.test.ts +1864 -0
  345. package/src/tests/yoga-upstream/generated/YGAlignContentTest.test.ts +5829 -0
  346. package/src/tests/yoga-upstream/generated/YGAlignItemsTest.test.ts +2444 -0
  347. package/src/tests/yoga-upstream/generated/YGAlignSelfTest.test.ts +293 -0
  348. package/src/tests/yoga-upstream/generated/YGAndroidNewsFeed.test.ts +315 -0
  349. package/src/tests/yoga-upstream/generated/YGAspectRatioTest.test.ts +231 -0
  350. package/src/tests/yoga-upstream/generated/YGAutoTest.test.ts +347 -0
  351. package/src/tests/yoga-upstream/generated/YGBorderTest.test.ts +256 -0
  352. package/src/tests/yoga-upstream/generated/YGBoxSizingTest.test.ts +2747 -0
  353. package/src/tests/yoga-upstream/generated/YGDimensionTest.test.ts +129 -0
  354. package/src/tests/yoga-upstream/generated/YGDisplayContentsTest.test.ts +109 -0
  355. package/src/tests/yoga-upstream/generated/YGDisplayTest.test.ts +1097 -0
  356. package/src/tests/yoga-upstream/generated/YGFlexDirectionTest.test.ts +4562 -0
  357. package/src/tests/yoga-upstream/generated/YGFlexTest.test.ts +677 -0
  358. package/src/tests/yoga-upstream/generated/YGFlexWrapTest.test.ts +2071 -0
  359. package/src/tests/yoga-upstream/generated/YGGapTest.test.ts +3303 -0
  360. package/src/tests/yoga-upstream/generated/YGIntrinsicSizeTest.test.ts +582 -0
  361. package/src/tests/yoga-upstream/generated/YGJustifyContentTest.test.ts +2150 -0
  362. package/src/tests/yoga-upstream/generated/YGMarginTest.test.ts +1963 -0
  363. package/src/tests/yoga-upstream/generated/YGMinMaxDimensionTest.test.ts +1446 -0
  364. package/src/tests/yoga-upstream/generated/YGPaddingTest.test.ts +354 -0
  365. package/src/tests/yoga-upstream/generated/YGPercentageTest.test.ts +1760 -0
  366. package/src/tests/yoga-upstream/generated/YGRoundingTest.test.ts +1161 -0
  367. package/src/tests/yoga-upstream/generated/YGSizeOverflowTest.test.ts +207 -0
  368. package/src/tests/yoga-upstream/generated/YGStaticPositionTest.test.ts +6338 -0
  369. package/src/tests/yoga-upstream/tools/MeasureCounter.ts +54 -0
  370. package/src/tests/yoga-upstream/tools/utils.ts +81 -0
  371. package/src/text-buffer-view.test.ts +722 -0
  372. package/src/text-buffer-view.ts +193 -0
  373. package/src/text-buffer.test.ts +512 -0
  374. package/src/text-buffer.ts +250 -0
  375. package/src/types.ts +199 -0
  376. package/src/utils.ts +88 -0
  377. package/src/yoga.ts +1146 -0
  378. package/src/zig/ansi.zig +502 -0
  379. package/src/zig/audio.zig +1206 -0
  380. package/src/zig/bench/README.md +50 -0
  381. package/src/zig/bench/buffer-color-blending_bench.zig +350 -0
  382. package/src/zig/bench/buffer-draw-box_bench.zig +786 -0
  383. package/src/zig/bench/buffer-draw-text-buffer_bench.zig +894 -0
  384. package/src/zig/bench/edit-buffer_bench.zig +476 -0
  385. package/src/zig/bench/editor-view_bench.zig +169 -0
  386. package/src/zig/bench/native-span-feed_bench.zig +107 -0
  387. package/src/zig/bench/rope-markers_bench.zig +713 -0
  388. package/src/zig/bench/rope_bench.zig +514 -0
  389. package/src/zig/bench/styled-text_bench.zig +476 -0
  390. package/src/zig/bench/text-buffer-coords_bench.zig +362 -0
  391. package/src/zig/bench/text-buffer-view_bench.zig +459 -0
  392. package/src/zig/bench/text-chunk-graphemes_bench.zig +272 -0
  393. package/src/zig/bench/utf8_bench.zig +799 -0
  394. package/src/zig/bench-utils.zig +431 -0
  395. package/src/zig/bench.zig +223 -0
  396. package/src/zig/buffer-methods.zig +215 -0
  397. package/src/zig/buffer.zig +2515 -0
  398. package/src/zig/build.zig +700 -0
  399. package/src/zig/build.zig.zon +19 -0
  400. package/src/zig/edit-buffer.zig +900 -0
  401. package/src/zig/editor-view.zig +812 -0
  402. package/src/zig/event-bus.zig +28 -0
  403. package/src/zig/event-emitter.zig +65 -0
  404. package/src/zig/file-logger.zig +92 -0
  405. package/src/zig/grapheme.zig +838 -0
  406. package/src/zig/handles.zig +267 -0
  407. package/src/zig/lib.zig +2813 -0
  408. package/src/zig/link.zig +349 -0
  409. package/src/zig/logger.zig +43 -0
  410. package/src/zig/mem-registry.zig +126 -0
  411. package/src/zig/miniaudio_shim.c +21 -0
  412. package/src/zig/native-renderable.zig +112 -0
  413. package/src/zig/native-span-feed-bench-lib.zig +7 -0
  414. package/src/zig/native-span-feed.zig +747 -0
  415. package/src/zig/renderer-output.zig +677 -0
  416. package/src/zig/renderer.zig +2106 -0
  417. package/src/zig/rope.zig +1220 -0
  418. package/src/zig/split-scrollback.zig +88 -0
  419. package/src/zig/syntax-style.zig +174 -0
  420. package/src/zig/terminal.zig +1652 -0
  421. package/src/zig/test.zig +82 -0
  422. package/src/zig/tests/README.md +18 -0
  423. package/src/zig/tests/ansi_test.zig +18 -0
  424. package/src/zig/tests/audio_test.zig +439 -0
  425. package/src/zig/tests/buffer-methods_test.zig +1111 -0
  426. package/src/zig/tests/buffer_test.zig +2926 -0
  427. package/src/zig/tests/edit-buffer-history_test.zig +299 -0
  428. package/src/zig/tests/edit-buffer_test.zig +1688 -0
  429. package/src/zig/tests/editor-view_test.zig +3454 -0
  430. package/src/zig/tests/event-emitter_test.zig +249 -0
  431. package/src/zig/tests/grapheme_test.zig +1329 -0
  432. package/src/zig/tests/handles_test.zig +123 -0
  433. package/src/zig/tests/link_test.zig +220 -0
  434. package/src/zig/tests/mem-registry_test.zig +473 -0
  435. package/src/zig/tests/memory_leak_regression_test.zig +234 -0
  436. package/src/zig/tests/native-renderable_test.zig +16 -0
  437. package/src/zig/tests/native-span-feed_test.zig +1266 -0
  438. package/src/zig/tests/renderer_test.zig +2604 -0
  439. package/src/zig/tests/rope-nested_test.zig +712 -0
  440. package/src/zig/tests/rope_fuzz_test.zig +238 -0
  441. package/src/zig/tests/rope_test.zig +2362 -0
  442. package/src/zig/tests/segment-merge.test.zig +148 -0
  443. package/src/zig/tests/split-scrollback_test.zig +70 -0
  444. package/src/zig/tests/syntax-style_test.zig +558 -0
  445. package/src/zig/tests/terminal_test.zig +1489 -0
  446. package/src/zig/tests/test-renderer.zig +126 -0
  447. package/src/zig/tests/text-buffer-drawing_test.zig +3291 -0
  448. package/src/zig/tests/text-buffer-highlights_test.zig +706 -0
  449. package/src/zig/tests/text-buffer-iterators_test.zig +776 -0
  450. package/src/zig/tests/text-buffer-segment_test.zig +320 -0
  451. package/src/zig/tests/text-buffer-selection_test.zig +1056 -0
  452. package/src/zig/tests/text-buffer-selection_viewport_test.zig +358 -0
  453. package/src/zig/tests/text-buffer-view_test.zig +3672 -0
  454. package/src/zig/tests/text-buffer_test.zig +2191 -0
  455. package/src/zig/tests/unicode-width-map.zon +3909 -0
  456. package/src/zig/tests/utf8_no_zwj_test.zig +260 -0
  457. package/src/zig/tests/utf8_test.zig +4060 -0
  458. package/src/zig/tests/utf8_wcwidth_cursor_test.zig +267 -0
  459. package/src/zig/tests/utf8_wcwidth_test.zig +357 -0
  460. package/src/zig/tests/word-wrap-editing_test.zig +498 -0
  461. package/src/zig/tests/wrap-cache-perf_test.zig +113 -0
  462. package/src/zig/tests/yoga_test.zig +65 -0
  463. package/src/zig/text-buffer-iterators.zig +496 -0
  464. package/src/zig/text-buffer-segment.zig +403 -0
  465. package/src/zig/text-buffer-view.zig +1428 -0
  466. package/src/zig/text-buffer.zig +1272 -0
  467. package/src/zig/utf8.zig +1954 -0
  468. package/src/zig/utils.zig +12 -0
  469. package/src/zig/uucode-stub.zig +76 -0
  470. package/src/zig/vendor/miniaudio/miniaudio.h +93468 -0
  471. package/src/zig/yoga.zig +658 -0
  472. package/src/zig-structs.ts +381 -0
  473. package/src/zig.ts +5260 -0
  474. package/tsconfig.build.json +17 -0
  475. package/tsconfig.json +27 -0
  476. package/tsconfig.node-test.json +138 -0
@@ -0,0 +1,1112 @@
1
+ import { test, expect, afterEach } from "bun:test"
2
+ import { Writable } from "stream"
3
+ import { createCliRenderer, CliRenderer, CliRenderEvents } from "../renderer.js"
4
+ import { ManualClock } from "../testing/manual-clock.js"
5
+ import { createTestStdin, TestWriteStream } from "../testing/test-streams.js"
6
+
7
+ // Collecting Writable used as a mock stdout. Because it is !== process.stdout,
8
+ // createCliRenderer allocates a NativeSpanFeed and pipes bytes through it.
9
+ class CollectingWriteStream extends TestWriteStream {
10
+ public readonly writes: Buffer[] = []
11
+ /** When > 0, delay the write callback by this many ms to simulate a slow consumer. */
12
+ public delayMs = 0
13
+
14
+ override _write(chunk: any, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
15
+ // Defensive copy: `Buffer.from(Uint8Array)` can alias the source's
16
+ // underlying ArrayBuffer. For feed-backed renderers the source is a view
17
+ // into Zig-owned chunk memory that is freed when the feed closes. Copy
18
+ // into a standalone Buffer so reads in assertions are safe after teardown.
19
+ const buf = Buffer.isBuffer(chunk) ? Buffer.from(chunk) : Buffer.from(chunk.slice())
20
+ this.writes.push(buf)
21
+ if (this.delayMs > 0) {
22
+ setTimeout(callback, this.delayMs)
23
+ } else {
24
+ callback()
25
+ }
26
+ }
27
+
28
+ getWrittenBytes(): Buffer {
29
+ return Buffer.concat(this.writes)
30
+ }
31
+
32
+ clearWrites(): void {
33
+ this.writes.length = 0
34
+ }
35
+ }
36
+
37
+ type CollectingStdout = CollectingWriteStream & NodeJS.WriteStream
38
+
39
+ function createCollectingStdout(columns = 80, rows = 24): CollectingStdout {
40
+ return new CollectingWriteStream(columns, rows) as CollectingStdout
41
+ }
42
+
43
+ function createPlainStdout(): NodeJS.WriteStream {
44
+ return new Writable({
45
+ write(_c, _e, cb) {
46
+ cb()
47
+ },
48
+ }) as NodeJS.WriteStream
49
+ }
50
+
51
+ function createRetryRenderer(feedBacked = false): { renderer: CliRenderer; clock: ManualClock } {
52
+ const clock = new ManualClock()
53
+ const renderer = new CliRenderer(
54
+ createTestStdin(),
55
+ feedBacked ? createCollectingStdout() : createPlainStdout(),
56
+ 80,
57
+ 24,
58
+ {
59
+ consoleMode: "disabled",
60
+ bufferedOutput: feedBacked ? undefined : "memory",
61
+ clock,
62
+ },
63
+ )
64
+ ;(renderer as any).updateScheduled = false
65
+ clock.runAll()
66
+ destroyFns.push(() => renderer.destroy())
67
+ return { renderer, clock }
68
+ }
69
+
70
+ function mockNativeRender(renderer: CliRenderer, render: (...args: any[]) => number): void {
71
+ const rendererAny = renderer as any
72
+ const originalRender = rendererAny.lib.render
73
+ rendererAny.lib.render = render
74
+ destroyFns.unshift(() => {
75
+ rendererAny.lib.render = originalRender
76
+ })
77
+ }
78
+
79
+ function deferFeedIdle(renderer: CliRenderer): { resolve: () => Promise<void>; calls: () => number } {
80
+ const feed = (renderer as any)._feed
81
+ const originalIdle = feed.idle.bind(feed)
82
+ let resolve = () => {}
83
+ let calls = 0
84
+ feed.idle = () => {
85
+ calls++
86
+ return new Promise<void>((done) => {
87
+ resolve = done
88
+ })
89
+ }
90
+ destroyFns.unshift(() => {
91
+ feed.idle = originalIdle
92
+ })
93
+ return {
94
+ resolve: async () => {
95
+ resolve()
96
+ await Promise.resolve()
97
+ await Promise.resolve()
98
+ },
99
+ calls: () => calls,
100
+ }
101
+ }
102
+
103
+ function forceNativeSplitSkip(renderer: CliRenderer): () => void {
104
+ const rendererAny = renderer as any
105
+ const originalCommit = rendererAny.lib.commitSplitFooterSnapshot.bind(rendererAny.lib)
106
+
107
+ rendererAny.lib.commitSplitFooterSnapshot = () => ({ renderOffset: rendererAny.renderOffset, status: 1 })
108
+
109
+ return () => {
110
+ rendererAny.lib.commitSplitFooterSnapshot = originalCommit
111
+ }
112
+ }
113
+
114
+ let destroyFns: Array<() => void> = []
115
+
116
+ afterEach(() => {
117
+ for (const fn of destroyFns) {
118
+ try {
119
+ fn()
120
+ } catch (e) {
121
+ console.error("cleanup error:", e)
122
+ }
123
+ }
124
+ destroyFns = []
125
+ })
126
+
127
+ // ---- Byte-routing behavior ----
128
+
129
+ test("non-process stdout: rendered bytes flow to the custom Writable", async () => {
130
+ const stdin = createTestStdin()
131
+ const stdout = createCollectingStdout(80, 24)
132
+
133
+ const renderer = await createCliRenderer({
134
+ stdin,
135
+ stdout,
136
+ })
137
+ destroyFns.push(() => renderer.destroy())
138
+
139
+ // Let setup writes settle.
140
+ await new Promise<void>((resolve) => setTimeout(resolve, 30))
141
+
142
+ const received = stdout.getWrittenBytes()
143
+ expect(received.length).toBeGreaterThan(0)
144
+ // ANSI escape sequences contain ESC (0x1b).
145
+ expect(received.includes(0x1b)).toBe(true)
146
+ })
147
+
148
+ test("split-footer custom stdout: native feed bytes bypass stdout capture", async () => {
149
+ const stdin = createTestStdin()
150
+ const stdout = createCollectingStdout(80, 24)
151
+
152
+ // Construct directly so the test isolates the feed/write bridge without
153
+ // setupTerminal() adding unrelated startup ANSI.
154
+ const renderer = new CliRenderer(stdin, stdout, 80, 24, {
155
+ screenMode: "split-footer",
156
+ consoleMode: "disabled",
157
+ })
158
+ destroyFns.push(() => renderer.destroy())
159
+
160
+ stdout.clearWrites()
161
+
162
+ renderer.setTerminalTitle("split-footer custom stdout")
163
+
164
+ // Renderer-owned ANSI must go straight to the sink, not back through the
165
+ // split-footer stdout-capture queue.
166
+ expect((renderer as any).externalOutputQueue.size).toBe(0)
167
+
168
+ await new Promise<void>((resolve) => setImmediate(resolve))
169
+
170
+ expect(stdout.getWrittenBytes().toString("binary")).toContain("\x1b]0;split-footer custom stdout\x07")
171
+ })
172
+
173
+ test("custom stdout resetTerminalBgColor routes through configured stdout", async () => {
174
+ const stdin = createTestStdin()
175
+ const stdout = createCollectingStdout(80, 24)
176
+
177
+ const renderer = new CliRenderer(stdin, stdout, 80, 24, {
178
+ consoleMode: "disabled",
179
+ })
180
+ destroyFns.push(() => renderer.destroy())
181
+
182
+ stdout.clearWrites()
183
+ renderer.resetTerminalBgColor()
184
+
185
+ await new Promise<void>((resolve) => setImmediate(resolve))
186
+
187
+ expect(stdout.getWrittenBytes().toString("binary")).toContain("\x1b]111\x07")
188
+ })
189
+
190
+ test("process.stdout: no feed is allocated (stdout-direct path)", async () => {
191
+ const renderer = await createCliRenderer({
192
+ stdin: process.stdin,
193
+ stdout: process.stdout,
194
+ bufferedOutput: "memory",
195
+ })
196
+ // Direct private-field inspection: no feed should be allocated when output
197
+ // goes straight to process.stdout.
198
+ expect((renderer as any)._feed).toBeNull()
199
+ expect(() => renderer.destroy()).not.toThrow()
200
+ })
201
+
202
+ test("feed-backed renderer retries one skipped frame after feed idle", async () => {
203
+ const { renderer, clock } = createRetryRenderer(true)
204
+ const idle = deferFeedIdle(renderer)
205
+ let calls = 0
206
+ let frames = 0
207
+ mockNativeRender(renderer, () => (calls++ === 0 ? 1 : 0))
208
+ renderer.on(CliRenderEvents.FRAME, () => frames++)
209
+
210
+ await (renderer as any).loop()
211
+ expect(calls).toBe(1)
212
+ expect(frames).toBe(0)
213
+ expect(idle.calls()).toBe(1)
214
+
215
+ await idle.resolve()
216
+ expect(renderer.getSchedulerState().hasScheduledRender).toBe(true)
217
+ clock.advance(17)
218
+
219
+ expect(calls).toBe(2)
220
+ expect(frames).toBe(1)
221
+ expect(renderer.getSchedulerState().hasScheduledRender).toBe(false)
222
+ })
223
+
224
+ test("feed-backed renderer retries immediately when feed pressure outlasts the frame interval", async () => {
225
+ const { renderer, clock } = createRetryRenderer(true)
226
+ const idle = deferFeedIdle(renderer)
227
+ let calls = 0
228
+ mockNativeRender(renderer, () => (calls++ === 0 ? 1 : 0))
229
+
230
+ await (renderer as any).loop()
231
+ clock.advance(100)
232
+ await idle.resolve()
233
+ clock.advance(0)
234
+
235
+ expect(calls).toBe(2)
236
+ })
237
+
238
+ test("feed-backed renderer coalesces requests while waiting for feed idle", async () => {
239
+ const { renderer, clock } = createRetryRenderer(true)
240
+ const idle = deferFeedIdle(renderer)
241
+ const observed: number[] = []
242
+ let state = 1
243
+ let calls = 0
244
+ renderer.setFrameCallback(async () => {
245
+ observed.push(state)
246
+ })
247
+ mockNativeRender(renderer, () => (calls++ === 0 ? 1 : 0))
248
+
249
+ await (renderer as any).loop()
250
+ state = 2
251
+ renderer.requestRender()
252
+ renderer.requestRender()
253
+ renderer.requestRender()
254
+ expect(calls).toBe(1)
255
+
256
+ await idle.resolve()
257
+ clock.advance(17)
258
+ await Promise.resolve()
259
+
260
+ expect(calls).toBe(2)
261
+ expect(observed).toEqual([1, 2])
262
+ })
263
+
264
+ test("starting a feed-backed renderer waits for a skipped frame's feed idle", async () => {
265
+ const { renderer, clock } = createRetryRenderer(true)
266
+ const idle = deferFeedIdle(renderer)
267
+ let calls = 0
268
+ mockNativeRender(renderer, () => (calls++ === 0 ? 1 : 0))
269
+
270
+ await (renderer as any).loop()
271
+ renderer.start()
272
+
273
+ expect(renderer.isRunning).toBe(true)
274
+ clock.advance(100)
275
+ expect(calls).toBe(1)
276
+
277
+ await idle.resolve()
278
+ clock.advance(0)
279
+
280
+ expect(calls).toBe(2)
281
+ expect(renderer.isRunning).toBe(true)
282
+ renderer.pause()
283
+ })
284
+
285
+ test("feed-backed renderer waits for each repeated skip", async () => {
286
+ const { renderer, clock } = createRetryRenderer(true)
287
+ const firstIdle = deferFeedIdle(renderer)
288
+ let calls = 0
289
+ mockNativeRender(renderer, () => (calls++ < 2 ? 1 : 0))
290
+
291
+ await (renderer as any).loop()
292
+ await firstIdle.resolve()
293
+ clock.advance(17)
294
+ expect(calls).toBe(2)
295
+ expect(firstIdle.calls()).toBe(2)
296
+
297
+ await firstIdle.resolve()
298
+ clock.advance(17)
299
+ expect(calls).toBe(3)
300
+ })
301
+
302
+ test("native failure does not retry and recovers on a later render request", async () => {
303
+ const { renderer, clock } = createRetryRenderer()
304
+ const originalError = console.error
305
+ const errors: unknown[][] = []
306
+ console.error = (...args: unknown[]) => errors.push(args)
307
+ destroyFns.unshift(() => {
308
+ console.error = originalError
309
+ })
310
+ let calls = 0
311
+ let frames = 0
312
+ mockNativeRender(renderer, () => (calls++ === 0 ? 2 : 0))
313
+ renderer.on(CliRenderEvents.FRAME, () => frames++)
314
+
315
+ await (renderer as any).loop()
316
+ clock.advance(1000)
317
+ expect(calls).toBe(1)
318
+ expect(frames).toBe(0)
319
+ expect(errors).toHaveLength(1)
320
+ expect(renderer.getSchedulerState().hasScheduledRender).toBe(false)
321
+
322
+ renderer.intermediateRender()
323
+
324
+ expect(calls).toBe(2)
325
+ expect(frames).toBe(1)
326
+ })
327
+
328
+ test("running renderer recovers from native failure on a later render request", async () => {
329
+ const { renderer, clock } = createRetryRenderer()
330
+ const originalError = console.error
331
+ console.error = () => {}
332
+ destroyFns.unshift(() => {
333
+ console.error = originalError
334
+ })
335
+ let calls = 0
336
+ mockNativeRender(renderer, () => (calls++ === 0 ? 2 : 0))
337
+
338
+ renderer.start()
339
+ expect(calls).toBe(1)
340
+ expect(renderer.isRunning).toBe(true)
341
+
342
+ renderer.requestRender()
343
+ clock.advance(17)
344
+
345
+ expect(calls).toBe(2)
346
+ })
347
+
348
+ test("feed-backed native failure does not wait for feed idle or retry", async () => {
349
+ const { renderer, clock } = createRetryRenderer(true)
350
+ const idle = deferFeedIdle(renderer)
351
+ const originalError = console.error
352
+ console.error = () => {}
353
+ destroyFns.unshift(() => {
354
+ console.error = originalError
355
+ })
356
+ let calls = 0
357
+ mockNativeRender(renderer, () => {
358
+ calls++
359
+ return 2
360
+ })
361
+
362
+ await (renderer as any).loop()
363
+ clock.advance(1000)
364
+
365
+ expect(calls).toBe(1)
366
+ expect(idle.calls()).toBe(0)
367
+ expect(renderer.getSchedulerState().hasScheduledRender).toBe(false)
368
+ })
369
+
370
+ test("unexpected skip without a feed does not retry", async () => {
371
+ const { renderer, clock } = createRetryRenderer()
372
+ const originalError = console.error
373
+ const errors: unknown[][] = []
374
+ console.error = (...args: unknown[]) => errors.push(args)
375
+ destroyFns.unshift(() => {
376
+ console.error = originalError
377
+ })
378
+ let calls = 0
379
+ mockNativeRender(renderer, () => {
380
+ calls++
381
+ return 1
382
+ })
383
+
384
+ await (renderer as any).loop()
385
+ clock.advance(1000)
386
+
387
+ expect(calls).toBe(1)
388
+ expect(errors).toHaveLength(1)
389
+ expect(renderer.getSchedulerState().hasScheduledRender).toBe(false)
390
+ })
391
+
392
+ for (const control of ["pause", "stop", "suspend", "destroy"] as const) {
393
+ test(`${control} cancels a feed-idle retry`, async () => {
394
+ const { renderer, clock } = createRetryRenderer(true)
395
+ const idle = deferFeedIdle(renderer)
396
+ let calls = 0
397
+ mockNativeRender(renderer, () => {
398
+ calls++
399
+ return 1
400
+ })
401
+
402
+ await (renderer as any).loop()
403
+ renderer[control]()
404
+ await idle.resolve()
405
+ clock.advance(17)
406
+
407
+ expect(calls).toBe(1)
408
+ })
409
+ }
410
+
411
+ for (const [control, state] of [
412
+ ["pause", "paused"],
413
+ ["stop", "stopped"],
414
+ ] as const) {
415
+ test(`one-shot render requested while ${state} retries after feed idle`, async () => {
416
+ const { renderer, clock } = createRetryRenderer(true)
417
+ const idle = deferFeedIdle(renderer)
418
+ let calls = 0
419
+ mockNativeRender(renderer, () => (calls++ === 0 ? 1 : 0))
420
+
421
+ renderer[control]()
422
+ renderer.requestRender()
423
+ clock.advance(17)
424
+ expect(calls).toBe(1)
425
+
426
+ await idle.resolve()
427
+ clock.advance(17)
428
+
429
+ expect(calls).toBe(2)
430
+ })
431
+ }
432
+
433
+ test("cancelling a skipped frame with an immediate rerender request resolves idle", async () => {
434
+ const { renderer } = createRetryRenderer(true)
435
+ const idle = deferFeedIdle(renderer)
436
+ renderer.setFrameCallback(async () => {
437
+ renderer.requestRender()
438
+ })
439
+ mockNativeRender(renderer, () => 1)
440
+
441
+ await (renderer as any).loop()
442
+ renderer.pause()
443
+ const idlePromise = renderer.idle()
444
+ let idleResolved = false
445
+ void idlePromise.then(() => {
446
+ idleResolved = true
447
+ })
448
+
449
+ await idle.resolve()
450
+ await Promise.resolve()
451
+
452
+ expect(idleResolved).toBe(true)
453
+ })
454
+
455
+ test("running renderer resumes after feed idle", async () => {
456
+ const { renderer, clock } = createRetryRenderer(true)
457
+ const idle = deferFeedIdle(renderer)
458
+ let calls = 0
459
+ mockNativeRender(renderer, () => (calls++ === 0 ? 1 : 0))
460
+
461
+ renderer.start()
462
+ expect(calls).toBe(1)
463
+ await idle.resolve()
464
+ clock.advance(17)
465
+
466
+ expect(calls).toBe(2)
467
+ expect(renderer.isRunning).toBe(true)
468
+ renderer.pause()
469
+ })
470
+
471
+ test("omitting stdin/stdout uses process streams", async () => {
472
+ const renderer = await createCliRenderer({
473
+ bufferedOutput: "memory",
474
+ })
475
+ expect(renderer.stdin).toBe(process.stdin)
476
+ destroyFns.push(() => renderer.destroy())
477
+ })
478
+
479
+ test("custom stdout defaults to remote env behavior", async () => {
480
+ const previous = process.env.OPENTUI_FORCE_WCWIDTH
481
+ process.env.OPENTUI_FORCE_WCWIDTH = "1"
482
+
483
+ try {
484
+ const defaultRemoteRenderer = await createCliRenderer({
485
+ stdin: createTestStdin(),
486
+ stdout: createCollectingStdout(80, 24),
487
+ })
488
+ destroyFns.push(() => defaultRemoteRenderer.destroy())
489
+
490
+ expect(defaultRemoteRenderer.widthMethod).toBe("unicode")
491
+
492
+ const localRenderer = await createCliRenderer({
493
+ stdin: createTestStdin(),
494
+ stdout: createCollectingStdout(80, 24),
495
+ remote: false,
496
+ })
497
+ destroyFns.push(() => localRenderer.destroy())
498
+
499
+ expect(localRenderer.widthMethod).toBe("wcwidth")
500
+ } finally {
501
+ if (previous === undefined) {
502
+ delete process.env.OPENTUI_FORCE_WCWIDTH
503
+ } else {
504
+ process.env.OPENTUI_FORCE_WCWIDTH = previous
505
+ }
506
+ }
507
+ })
508
+
509
+ // ---- Shutdown bytes reach the remote Writable (F1 regression test) ----
510
+
511
+ test("destroy emits shutdown ANSI sequence through the custom Writable", async () => {
512
+ const stdin = createTestStdin()
513
+ const stdout = createCollectingStdout(80, 24)
514
+
515
+ const renderer = await createCliRenderer({
516
+ stdin,
517
+ stdout,
518
+ })
519
+
520
+ // Let setup output settle, then clear so we can isolate shutdown output.
521
+ await new Promise<void>((resolve) => setTimeout(resolve, 30))
522
+ stdout.clearWrites()
523
+
524
+ renderer.destroy()
525
+
526
+ // Let final writes settle.
527
+ await new Promise<void>((resolve) => setTimeout(resolve, 50))
528
+
529
+ const shutdownBytes = stdout.getWrittenBytes().toString("binary")
530
+
531
+ // The shutdown sequence must include at least:
532
+ // - showCursor (ANSI.showCursor = ESC[?25h) so the user isn't left with a hidden cursor
533
+ // - either the reset-cursor-color sequence or the default-cursor-style sequence
534
+ // This is the regression test for the teardown-order bug where the data
535
+ // handler was detached before destroyRenderer emitted shutdown, causing
536
+ // those bytes to be discarded.
537
+ expect(shutdownBytes.length).toBeGreaterThan(0)
538
+ expect(shutdownBytes).toContain("\x1b[?25h") // showCursor
539
+ })
540
+
541
+ // ---- Backpressure ----
542
+
543
+ test("slow Writable marks feed as backpressured until write callback settles", async () => {
544
+ const stdin = createTestStdin()
545
+ const stdout = createCollectingStdout(80, 24)
546
+ stdout.delayMs = 50
547
+
548
+ const renderer = await createCliRenderer({
549
+ stdin,
550
+ stdout,
551
+ })
552
+ destroyFns.push(() => {
553
+ stdout.delayMs = 0
554
+ renderer.destroy()
555
+ })
556
+
557
+ const feed = (renderer as any)._feed
558
+ expect(feed).not.toBeNull()
559
+
560
+ renderer.setTerminalTitle("slow-write")
561
+ await new Promise<void>((resolve) => setImmediate(resolve))
562
+
563
+ expect(feed.isBackpressured()).toBe(true)
564
+
565
+ stdout.delayMs = 0
566
+ await feed.idle()
567
+
568
+ expect(feed.isBackpressured()).toBe(false)
569
+ })
570
+
571
+ test("split-footer custom stdout can flush captured commits while feed writes are in flight", async () => {
572
+ const stdin = createTestStdin()
573
+ const stdout = createCollectingStdout(80, 24)
574
+ stdout.delayMs = 100
575
+
576
+ const renderer = new CliRenderer(stdin, stdout, 80, 24, {
577
+ screenMode: "split-footer",
578
+ consoleMode: "disabled",
579
+ })
580
+ destroyFns.push(() => {
581
+ stdout.delayMs = 0
582
+ renderer.destroy()
583
+ })
584
+
585
+ const feed = (renderer as any)._feed
586
+ expect(feed).not.toBeNull()
587
+
588
+ renderer.setTerminalTitle("pin-feed")
589
+ await new Promise<void>((resolve) => setImmediate(resolve))
590
+ expect(feed.isBackpressured()).toBe(true)
591
+
592
+ stdout.write("captured\n")
593
+ await (renderer as any).loop()
594
+
595
+ expect((renderer as any).externalOutputQueue.size).toBe(0)
596
+
597
+ stdout.delayMs = 0
598
+ await feed.idle()
599
+ expect(stdout.getWrittenBytes().toString("binary")).toContain("captured")
600
+ })
601
+
602
+ test("split-footer custom stdout retains captured commits when native skips", async () => {
603
+ const stdin = createTestStdin()
604
+ const stdout = createCollectingStdout(80, 24)
605
+
606
+ const renderer = new CliRenderer(stdin, stdout, 80, 24, {
607
+ screenMode: "split-footer",
608
+ consoleMode: "disabled",
609
+ })
610
+ destroyFns.push(() => renderer.destroy())
611
+
612
+ const restoreNative = forceNativeSplitSkip(renderer)
613
+
614
+ stdout.write("captured-while-native-skipped\n")
615
+ const rendererAny = renderer as any
616
+ expect(rendererAny.externalOutputQueue.size).toBeGreaterThan(0)
617
+
618
+ try {
619
+ await rendererAny.loop()
620
+ expect(rendererAny.externalOutputQueue.size).toBeGreaterThan(0)
621
+ } finally {
622
+ restoreNative()
623
+ }
624
+ })
625
+
626
+ test("split-footer coalesces render requests while waiting for feed idle", async () => {
627
+ const clock = new ManualClock()
628
+ const stdout = createCollectingStdout(80, 24)
629
+ const renderer = new CliRenderer(createTestStdin(), stdout, 80, 24, {
630
+ screenMode: "split-footer",
631
+ consoleMode: "disabled",
632
+ clock,
633
+ })
634
+ ;(renderer as any).updateScheduled = false
635
+ clock.runAll()
636
+ destroyFns.push(() => renderer.destroy())
637
+
638
+ const idle = deferFeedIdle(renderer)
639
+ const rendererAny = renderer as any
640
+ const originalCommit = rendererAny.lib.commitSplitFooterSnapshot
641
+ let calls = 0
642
+ rendererAny.lib.commitSplitFooterSnapshot = () => {
643
+ calls++
644
+ return { renderOffset: rendererAny.renderOffset, status: calls === 1 ? 1 : 0 }
645
+ }
646
+ destroyFns.unshift(() => {
647
+ rendererAny.lib.commitSplitFooterSnapshot = originalCommit
648
+ })
649
+
650
+ stdout.write("first\n")
651
+ clock.advance(17)
652
+ await Promise.resolve()
653
+ expect(calls).toBe(1)
654
+
655
+ stdout.write("second\n")
656
+ renderer.requestRender()
657
+ clock.advance(100)
658
+ await Promise.resolve()
659
+
660
+ expect(calls).toBe(1)
661
+ expect(idle.calls()).toBe(1)
662
+ expect(rendererAny.externalOutputQueue.size).toBe(2)
663
+
664
+ await idle.resolve()
665
+ clock.advance(0)
666
+ await Promise.resolve()
667
+
668
+ expect(calls).toBe(3)
669
+ expect(rendererAny.externalOutputQueue.size).toBe(0)
670
+ })
671
+
672
+ test("split-footer custom stdout retains captured commits when native fails and retries", async () => {
673
+ const stdin = createTestStdin()
674
+ const stdout = createCollectingStdout(80, 24)
675
+
676
+ const renderer = new CliRenderer(stdin, stdout, 80, 24, {
677
+ screenMode: "split-footer",
678
+ consoleMode: "disabled",
679
+ })
680
+ destroyFns.push(() => renderer.destroy())
681
+
682
+ const rendererAny = renderer as any
683
+ const originalCommit = rendererAny.lib.commitSplitFooterSnapshot.bind(rendererAny.lib)
684
+ let calls = 0
685
+ rendererAny.lib.commitSplitFooterSnapshot = () => {
686
+ calls++
687
+ return { renderOffset: rendererAny.renderOffset, status: 2 }
688
+ }
689
+
690
+ stdout.write("captured-while-native-failed\n")
691
+ expect(rendererAny.externalOutputQueue.size).toBeGreaterThan(0)
692
+
693
+ try {
694
+ await rendererAny.loop()
695
+ expect(calls).toBeGreaterThan(0)
696
+ expect(rendererAny.externalOutputQueue.size).toBeGreaterThan(0)
697
+ } finally {
698
+ rendererAny.lib.commitSplitFooterSnapshot = originalCommit
699
+ }
700
+
701
+ await rendererAny.loop()
702
+ await (rendererAny._feed?.idle() ?? Promise.resolve())
703
+
704
+ expect(rendererAny.externalOutputQueue.size).toBe(0)
705
+ expect(stdout.getWrittenBytes().toString("binary")).toContain("captured-while-native-failed")
706
+ })
707
+
708
+ test("split-footer native failure without a feed does not schedule automatic retries", async () => {
709
+ const clock = new ManualClock()
710
+ const stdout = createPlainStdout()
711
+ const renderer = new CliRenderer(createTestStdin(), stdout, 80, 24, {
712
+ screenMode: "split-footer",
713
+ consoleMode: "disabled",
714
+ bufferedOutput: "memory",
715
+ clock,
716
+ })
717
+ ;(renderer as any).updateScheduled = false
718
+ clock.runAll()
719
+ destroyFns.push(() => renderer.destroy())
720
+
721
+ const rendererAny = renderer as any
722
+ const originalCommit = rendererAny.lib.commitSplitFooterSnapshot
723
+ const originalError = console.error
724
+ let calls = 0
725
+ rendererAny.lib.commitSplitFooterSnapshot = () => {
726
+ calls++
727
+ return { renderOffset: rendererAny.renderOffset, status: 2 }
728
+ }
729
+ console.error = () => {}
730
+ destroyFns.unshift(() => {
731
+ rendererAny.lib.commitSplitFooterSnapshot = originalCommit
732
+ console.error = originalError
733
+ })
734
+
735
+ stdout.write("captured-while-native-failed\n")
736
+ rendererAny.updateScheduled = false
737
+ await rendererAny.loop()
738
+ expect(calls).toBe(1)
739
+
740
+ clock.advance(1000)
741
+
742
+ expect(calls).toBe(1)
743
+ expect(renderer.getSchedulerState().hasScheduledRender).toBe(false)
744
+ })
745
+
746
+ test("capture-to-passthrough flushes queued split-footer commits while feed is backpressured", async () => {
747
+ const stdin = createTestStdin()
748
+ const stdout = createCollectingStdout(80, 24)
749
+ stdout.delayMs = 30
750
+
751
+ const renderer = new CliRenderer(stdin, stdout, 80, 24, {
752
+ screenMode: "split-footer",
753
+ consoleMode: "disabled",
754
+ })
755
+ destroyFns.push(() => {
756
+ stdout.delayMs = 0
757
+ renderer.destroy()
758
+ })
759
+
760
+ const feed = (renderer as any)._feed
761
+ expect(feed).not.toBeNull()
762
+
763
+ renderer.setTerminalTitle("pin-feed-before-mode-switch")
764
+ await new Promise<void>((resolve) => setImmediate(resolve))
765
+ expect(feed.isBackpressured()).toBe(true)
766
+
767
+ stdout.write("captured-before-mode-switch\n")
768
+ expect((renderer as any).externalOutputQueue.size).toBeGreaterThan(0)
769
+
770
+ renderer.externalOutputMode = "passthrough"
771
+ stdout.delayMs = 0
772
+
773
+ await new Promise<void>((resolve) => setTimeout(resolve, 80))
774
+
775
+ expect(stdout.getWrittenBytes().toString("binary")).toContain("captured-before-mode-switch")
776
+ expect((renderer as any).externalOutputQueue.size).toBe(0)
777
+ })
778
+
779
+ test("destroy resolves idle waiters when a feed-idle render was scheduled", async () => {
780
+ const stdin = createTestStdin()
781
+ const stdout = createCollectingStdout(80, 24)
782
+ stdout.delayMs = 30
783
+
784
+ const renderer = new CliRenderer(stdin, stdout, 80, 24, {
785
+ screenMode: "split-footer",
786
+ consoleMode: "disabled",
787
+ })
788
+ destroyFns.push(() => {
789
+ stdout.delayMs = 0
790
+ renderer.destroy()
791
+ })
792
+
793
+ const feed = (renderer as any)._feed
794
+ expect(feed).not.toBeNull()
795
+
796
+ renderer.setTerminalTitle("pin-feed-before-idle")
797
+ await new Promise<void>((resolve) => setImmediate(resolve))
798
+ expect(feed.isBackpressured()).toBe(true)
799
+
800
+ const restoreNative = forceNativeSplitSkip(renderer)
801
+ stdout.write("captured-before-idle-destroy\n")
802
+ try {
803
+ await (renderer as any).loop()
804
+ expect((renderer as any).feedIdleRenderScheduled).toBe(true)
805
+ } finally {
806
+ restoreNative()
807
+ }
808
+
809
+ let idleResolved = false
810
+ const idlePromise = renderer.idle().then(() => {
811
+ idleResolved = true
812
+ })
813
+
814
+ renderer.destroy()
815
+ stdout.delayMs = 0
816
+ await Promise.resolve()
817
+
818
+ expect(idleResolved).toBe(true)
819
+ await idlePromise
820
+
821
+ await new Promise<void>((resolve) => setTimeout(resolve, 80))
822
+ expect(stdout.getWrittenBytes().toString("binary")).toContain("captured-before-idle-destroy")
823
+ expect((renderer as any).externalOutputQueue.size).toBe(0)
824
+ })
825
+
826
+ test("suspend resolves idle waiters when a feed-idle render was scheduled", async () => {
827
+ const stdin = createTestStdin()
828
+ const stdout = createCollectingStdout(80, 24)
829
+ stdout.delayMs = 30
830
+
831
+ const renderer = new CliRenderer(stdin, stdout, 80, 24, {
832
+ screenMode: "split-footer",
833
+ consoleMode: "disabled",
834
+ })
835
+ destroyFns.push(() => {
836
+ stdout.delayMs = 0
837
+ renderer.destroy()
838
+ })
839
+
840
+ const feed = (renderer as any)._feed
841
+ expect(feed).not.toBeNull()
842
+
843
+ renderer.setTerminalTitle("pin-feed-before-suspend")
844
+ await new Promise<void>((resolve) => setImmediate(resolve))
845
+ expect(feed.isBackpressured()).toBe(true)
846
+
847
+ const restoreNative = forceNativeSplitSkip(renderer)
848
+ stdout.write("captured-before-suspend\n")
849
+ try {
850
+ await (renderer as any).loop()
851
+ expect((renderer as any).feedIdleRenderScheduled).toBe(true)
852
+ } finally {
853
+ restoreNative()
854
+ }
855
+
856
+ let idleResolved = false
857
+ const idlePromise = renderer.idle().then(() => {
858
+ idleResolved = true
859
+ })
860
+
861
+ renderer.suspend()
862
+ stdout.delayMs = 0
863
+ await feed.idle()
864
+ await Promise.resolve()
865
+
866
+ expect(idleResolved).toBe(true)
867
+ await idlePromise
868
+ })
869
+
870
+ // ---- Dimension fallback ----
871
+
872
+ test("dimensions: stdout.columns wins over config.width", async () => {
873
+ const stdin = createTestStdin()
874
+ const stdout = createCollectingStdout(120, 30)
875
+
876
+ const renderer = await createCliRenderer({
877
+ stdin,
878
+ stdout,
879
+ width: 40,
880
+ height: 10,
881
+ bufferedOutput: "memory",
882
+ })
883
+ destroyFns.push(() => renderer.destroy())
884
+
885
+ expect(renderer.width).toBe(120)
886
+ expect(renderer.height).toBe(30)
887
+ })
888
+
889
+ test("dimensions: config.width used when stdout lacks columns", async () => {
890
+ const stdin = createTestStdin()
891
+ const stdout = createPlainStdout()
892
+
893
+ const renderer = await createCliRenderer({
894
+ stdin,
895
+ stdout,
896
+ width: 100,
897
+ height: 50,
898
+ bufferedOutput: "memory",
899
+ })
900
+ destroyFns.push(() => renderer.destroy())
901
+
902
+ expect(renderer.width).toBe(100)
903
+ expect(renderer.height).toBe(50)
904
+ })
905
+
906
+ test("dimensions: config.width used when stdout reports zero columns", async () => {
907
+ const stdin = createTestStdin()
908
+ const stdout = createCollectingStdout(0, 0)
909
+
910
+ const renderer = await createCliRenderer({
911
+ stdin,
912
+ stdout,
913
+ width: 100,
914
+ height: 50,
915
+ bufferedOutput: "memory",
916
+ })
917
+ destroyFns.push(() => renderer.destroy())
918
+
919
+ expect(renderer.width).toBe(100)
920
+ expect(renderer.height).toBe(50)
921
+ })
922
+
923
+ test("dimensions: defaults 80x24 when no stdout columns and no config", async () => {
924
+ const stdin = createTestStdin()
925
+ const stdout = createPlainStdout()
926
+
927
+ const renderer = await createCliRenderer({
928
+ stdin,
929
+ stdout,
930
+ bufferedOutput: "memory",
931
+ })
932
+ destroyFns.push(() => renderer.destroy())
933
+
934
+ expect(renderer.width).toBe(80)
935
+ expect(renderer.height).toBe(24)
936
+ })
937
+
938
+ test("dimensions: defaults 80x24 when stdout reports zero columns and no config", async () => {
939
+ const stdin = createTestStdin()
940
+ const stdout = createCollectingStdout(0, 0)
941
+
942
+ const renderer = await createCliRenderer({
943
+ stdin,
944
+ stdout,
945
+ bufferedOutput: "memory",
946
+ })
947
+ destroyFns.push(() => renderer.destroy())
948
+
949
+ expect(renderer.width).toBe(80)
950
+ expect(renderer.height).toBe(24)
951
+ })
952
+
953
+ // ---- Duck-typed stream capabilities ----
954
+
955
+ test("stdin without setRawMode: start/suspend/resume/destroy all succeed", async () => {
956
+ const stdin = createTestStdin() // Readable has no setRawMode
957
+ const stdout = createCollectingStdout(80, 24)
958
+
959
+ const renderer = await createCliRenderer({
960
+ stdin,
961
+ stdout,
962
+ bufferedOutput: "memory",
963
+ })
964
+
965
+ expect(() => renderer.suspend()).not.toThrow()
966
+ expect(() => renderer.resume()).not.toThrow()
967
+ expect(() => renderer.destroy()).not.toThrow()
968
+ })
969
+
970
+ // ---- Public resize API ----
971
+
972
+ test("resize(w, h) updates dimensions and fires RESIZE event", async () => {
973
+ const stdin = createTestStdin()
974
+ const stdout = createCollectingStdout(80, 24)
975
+
976
+ const renderer = await createCliRenderer({
977
+ stdin,
978
+ stdout,
979
+ bufferedOutput: "memory",
980
+ })
981
+ destroyFns.push(() => renderer.destroy())
982
+
983
+ let eventFired = false
984
+ let eventW = 0
985
+ let eventH = 0
986
+ renderer.on(CliRenderEvents.RESIZE, (w: number, h: number) => {
987
+ eventFired = true
988
+ eventW = w
989
+ eventH = h
990
+ })
991
+
992
+ renderer.resize(120, 40)
993
+
994
+ expect(eventFired).toBe(true)
995
+ expect(eventW).toBe(120)
996
+ expect(eventH).toBe(40)
997
+ expect(renderer.width).toBe(120)
998
+ expect(renderer.height).toBe(40)
999
+ })
1000
+
1001
+ test("resize() after destroy is a no-op", async () => {
1002
+ const stdin = createTestStdin()
1003
+ const stdout = createCollectingStdout(80, 24)
1004
+
1005
+ const renderer = await createCliRenderer({
1006
+ stdin,
1007
+ stdout,
1008
+ bufferedOutput: "memory",
1009
+ })
1010
+
1011
+ renderer.destroy()
1012
+ expect(() => renderer.resize(100, 50)).not.toThrow()
1013
+ })
1014
+
1015
+ // ---- Full feed teardown path ----
1016
+
1017
+ test("full feed teardown after successful setup does not throw", async () => {
1018
+ const stdin = createTestStdin()
1019
+ const stdout = createCollectingStdout(80, 24)
1020
+
1021
+ const renderer = await createCliRenderer({
1022
+ stdin,
1023
+ stdout,
1024
+ })
1025
+ // Exercises the full drain → destroyRenderer → drain → detach → close path.
1026
+ expect(() => renderer.destroy()).not.toThrow()
1027
+ })
1028
+
1029
+ // ---- Destroy resilience ----
1030
+
1031
+ test("constructor cleans up listeners when input setup fails", async () => {
1032
+ const stdin = createTestStdin()
1033
+ const stdout = createCollectingStdout(80, 24)
1034
+ const calls: boolean[] = []
1035
+ const processEvents = ["warning", "uncaughtException", "unhandledRejection", "beforeExit"] as const
1036
+ const listenerCounts = new Map(processEvents.map((event) => [event, process.listenerCount(event)]))
1037
+
1038
+ stdin.setRawMode = (enabled) => {
1039
+ calls.push(enabled)
1040
+ if (enabled) {
1041
+ throw new Error("raw mode setup failed")
1042
+ }
1043
+ return stdin
1044
+ }
1045
+
1046
+ await expect(
1047
+ createCliRenderer({
1048
+ stdin,
1049
+ stdout,
1050
+ exitSignals: [],
1051
+ }),
1052
+ ).rejects.toThrow("raw mode setup failed")
1053
+
1054
+ expect(calls).toEqual([true, false])
1055
+ expect(stdin.listenerCount("data")).toBe(0)
1056
+ for (const event of processEvents) {
1057
+ expect(process.listenerCount(event)).toBe(listenerCounts.get(event) ?? 0)
1058
+ }
1059
+ })
1060
+
1061
+ test("destroy tolerates drainAll throwing", async () => {
1062
+ const stdin = createTestStdin()
1063
+ const stdout = createCollectingStdout(80, 24)
1064
+
1065
+ const renderer = await createCliRenderer({
1066
+ stdin,
1067
+ stdout,
1068
+ })
1069
+
1070
+ // Monkey-patch drainAll on the private feed handle to throw on the first
1071
+ // two calls (one before destroyRenderer, one after), then pass through.
1072
+ const feed = (renderer as any)._feed
1073
+ expect(feed).not.toBeNull()
1074
+ const originalDrainAll = feed.drainAll.bind(feed)
1075
+ let calls = 0
1076
+ feed.drainAll = () => {
1077
+ calls++
1078
+ if (calls <= 2) throw new Error("simulated drain failure")
1079
+ return originalDrainAll()
1080
+ }
1081
+
1082
+ // destroy must swallow the drainAll exceptions and still complete the
1083
+ // rest of the teardown path.
1084
+ expect(() => renderer.destroy()).not.toThrow()
1085
+ expect(calls).toBeGreaterThanOrEqual(2)
1086
+ })
1087
+
1088
+ // ---- onError handler wire-up ----
1089
+
1090
+ test("feed.onError handler registration and detach work", async () => {
1091
+ const stdin = createTestStdin()
1092
+ const stdout = createCollectingStdout(80, 24)
1093
+
1094
+ const renderer = await createCliRenderer({
1095
+ stdin,
1096
+ stdout,
1097
+ })
1098
+ destroyFns.push(() => renderer.destroy())
1099
+
1100
+ // The renderer-internal handler is already registered. We register a
1101
+ // secondary one and verify the detach function it returns.
1102
+ //
1103
+ // Note: this test only verifies the wire-up (subscribe + detach). There
1104
+ // is currently no supported API to synthetically trigger an EventId.Error
1105
+ // event on the feed, so end-to-end invocation is a coverage gap tracked
1106
+ // for a future NativeSpanFeed test-harness hook.
1107
+ const feed = (renderer as any)._feed
1108
+ expect(feed).not.toBeNull()
1109
+ const detach = feed.onError(() => {})
1110
+ expect(typeof detach).toBe("function")
1111
+ expect(() => detach()).not.toThrow()
1112
+ })