@mdxui/terminal 2.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 (191) hide show
  1. package/README.md +571 -0
  2. package/dist/ansi-css-Sk5mWtdK.d.ts +119 -0
  3. package/dist/ansi-css-V6JIHGsM.d.ts +119 -0
  4. package/dist/ansi-css-_3eSEU9d.d.ts +119 -0
  5. package/dist/chunk-3EFDH7PK.js +5235 -0
  6. package/dist/chunk-3RG5ZIWI.js +10 -0
  7. package/dist/chunk-3X5IR6WE.js +884 -0
  8. package/dist/chunk-4FV5ZDCE.js +5236 -0
  9. package/dist/chunk-4OVMSF2J.js +243 -0
  10. package/dist/chunk-63FEETIS.js +4048 -0
  11. package/dist/chunk-B43KP7XJ.js +884 -0
  12. package/dist/chunk-BMTJXWUV.js +655 -0
  13. package/dist/chunk-C3SVH4N7.js +882 -0
  14. package/dist/chunk-EVWR7Y47.js +874 -0
  15. package/dist/chunk-F6A5VWUC.js +1285 -0
  16. package/dist/chunk-FD7KW7GE.js +882 -0
  17. package/dist/chunk-GBQ6UD6I.js +655 -0
  18. package/dist/chunk-GMDD3M6U.js +5227 -0
  19. package/dist/chunk-JBHRXOXM.js +1058 -0
  20. package/dist/chunk-JFOO3EYO.js +1182 -0
  21. package/dist/chunk-JQ5H3WXL.js +1291 -0
  22. package/dist/chunk-JQD5NASE.js +234 -0
  23. package/dist/chunk-KRHJP5R7.js +592 -0
  24. package/dist/chunk-KWF6WVJE.js +962 -0
  25. package/dist/chunk-LHYQVN3H.js +1038 -0
  26. package/dist/chunk-M3TLQLGC.js +1032 -0
  27. package/dist/chunk-MVW4Q5OP.js +240 -0
  28. package/dist/chunk-NXCZSWLU.js +1294 -0
  29. package/dist/chunk-O25TNRO6.js +607 -0
  30. package/dist/chunk-PNECDA2I.js +884 -0
  31. package/dist/chunk-QIHWRLJR.js +962 -0
  32. package/dist/chunk-QW5YMQ7K.js +882 -0
  33. package/dist/chunk-R5U7XKVJ.js +16 -0
  34. package/dist/chunk-RP2MVQLR.js +962 -0
  35. package/dist/chunk-TP6RXGXA.js +1087 -0
  36. package/dist/chunk-TQQSTITZ.js +655 -0
  37. package/dist/chunk-X24GWXQV.js +1281 -0
  38. package/dist/components/index.d.ts +802 -0
  39. package/dist/components/index.js +149 -0
  40. package/dist/data/index.d.ts +2554 -0
  41. package/dist/data/index.js +51 -0
  42. package/dist/forms/index.d.ts +1596 -0
  43. package/dist/forms/index.js +464 -0
  44. package/dist/index-CQRFZntR.d.ts +867 -0
  45. package/dist/index.d.ts +579 -0
  46. package/dist/index.js +786 -0
  47. package/dist/interactive-D0JkWosD.d.ts +217 -0
  48. package/dist/keyboard/index.d.ts +2 -0
  49. package/dist/keyboard/index.js +43 -0
  50. package/dist/renderers/index.d.ts +546 -0
  51. package/dist/renderers/index.js +2157 -0
  52. package/dist/storybook/index.d.ts +396 -0
  53. package/dist/storybook/index.js +641 -0
  54. package/dist/theme/index.d.ts +1339 -0
  55. package/dist/theme/index.js +123 -0
  56. package/dist/types-Bxu5PAgA.d.ts +710 -0
  57. package/dist/types-CIlop5Ji.d.ts +701 -0
  58. package/dist/types-Ca8p_p5X.d.ts +710 -0
  59. package/package.json +90 -0
  60. package/src/__tests__/components/data/card.test.ts +458 -0
  61. package/src/__tests__/components/data/list.test.ts +473 -0
  62. package/src/__tests__/components/data/metrics.test.ts +541 -0
  63. package/src/__tests__/components/data/table.test.ts +448 -0
  64. package/src/__tests__/components/input/field.test.ts +555 -0
  65. package/src/__tests__/components/input/form.test.ts +870 -0
  66. package/src/__tests__/components/input/search.test.ts +1238 -0
  67. package/src/__tests__/components/input/select.test.ts +658 -0
  68. package/src/__tests__/components/navigation/breadcrumb.test.ts +923 -0
  69. package/src/__tests__/components/navigation/command-palette.test.ts +1095 -0
  70. package/src/__tests__/components/navigation/sidebar.test.ts +1018 -0
  71. package/src/__tests__/components/navigation/tabs.test.ts +995 -0
  72. package/src/__tests__/components.test.tsx +1197 -0
  73. package/src/__tests__/core/compiler.test.ts +986 -0
  74. package/src/__tests__/core/parser.test.ts +785 -0
  75. package/src/__tests__/core/tier-switcher.test.ts +1103 -0
  76. package/src/__tests__/core/types.test.ts +1398 -0
  77. package/src/__tests__/data/collections.test.ts +1337 -0
  78. package/src/__tests__/data/db.test.ts +1265 -0
  79. package/src/__tests__/data/reactive.test.ts +1010 -0
  80. package/src/__tests__/data/sync.test.ts +1614 -0
  81. package/src/__tests__/errors.test.ts +660 -0
  82. package/src/__tests__/forms/integration.test.ts +444 -0
  83. package/src/__tests__/integration.test.ts +905 -0
  84. package/src/__tests__/keyboard.test.ts +1791 -0
  85. package/src/__tests__/renderer.test.ts +489 -0
  86. package/src/__tests__/renderers/ansi-css.test.ts +948 -0
  87. package/src/__tests__/renderers/ansi.test.ts +1366 -0
  88. package/src/__tests__/renderers/ascii.test.ts +1360 -0
  89. package/src/__tests__/renderers/interactive.test.ts +2353 -0
  90. package/src/__tests__/renderers/markdown.test.ts +1483 -0
  91. package/src/__tests__/renderers/text.test.ts +1369 -0
  92. package/src/__tests__/renderers/unicode.test.ts +1307 -0
  93. package/src/__tests__/theme.test.ts +639 -0
  94. package/src/__tests__/utils/assertions.ts +685 -0
  95. package/src/__tests__/utils/index.ts +115 -0
  96. package/src/__tests__/utils/test-renderer.ts +381 -0
  97. package/src/__tests__/utils/utils.test.ts +560 -0
  98. package/src/components/containers/card.ts +56 -0
  99. package/src/components/containers/dialog.ts +53 -0
  100. package/src/components/containers/index.ts +9 -0
  101. package/src/components/containers/panel.ts +59 -0
  102. package/src/components/feedback/badge.ts +40 -0
  103. package/src/components/feedback/index.ts +8 -0
  104. package/src/components/feedback/spinner.ts +23 -0
  105. package/src/components/helpers.ts +81 -0
  106. package/src/components/index.ts +153 -0
  107. package/src/components/layout/breadcrumb.ts +31 -0
  108. package/src/components/layout/index.ts +10 -0
  109. package/src/components/layout/list.ts +29 -0
  110. package/src/components/layout/sidebar.ts +79 -0
  111. package/src/components/layout/table.ts +62 -0
  112. package/src/components/primitives/box.ts +95 -0
  113. package/src/components/primitives/button.ts +54 -0
  114. package/src/components/primitives/index.ts +11 -0
  115. package/src/components/primitives/input.ts +88 -0
  116. package/src/components/primitives/select.ts +97 -0
  117. package/src/components/primitives/text.ts +60 -0
  118. package/src/components/render.ts +155 -0
  119. package/src/components/templates/app.ts +43 -0
  120. package/src/components/templates/index.ts +8 -0
  121. package/src/components/templates/site.ts +54 -0
  122. package/src/components/types.ts +777 -0
  123. package/src/core/compiler.ts +718 -0
  124. package/src/core/parser.ts +127 -0
  125. package/src/core/tier-switcher.ts +607 -0
  126. package/src/core/types.ts +672 -0
  127. package/src/data/collection.ts +316 -0
  128. package/src/data/collections.ts +50 -0
  129. package/src/data/context.tsx +174 -0
  130. package/src/data/db.ts +127 -0
  131. package/src/data/hooks.ts +532 -0
  132. package/src/data/index.ts +138 -0
  133. package/src/data/reactive.ts +1225 -0
  134. package/src/data/saas-collections.ts +375 -0
  135. package/src/data/sync.ts +1213 -0
  136. package/src/data/types.ts +660 -0
  137. package/src/forms/converters.ts +512 -0
  138. package/src/forms/index.ts +133 -0
  139. package/src/forms/schemas.ts +403 -0
  140. package/src/forms/types.ts +476 -0
  141. package/src/index.ts +542 -0
  142. package/src/keyboard/focus.ts +748 -0
  143. package/src/keyboard/index.ts +96 -0
  144. package/src/keyboard/integration.ts +371 -0
  145. package/src/keyboard/manager.ts +377 -0
  146. package/src/keyboard/presets.ts +90 -0
  147. package/src/renderers/ansi-css.ts +576 -0
  148. package/src/renderers/ansi.ts +802 -0
  149. package/src/renderers/ascii.ts +680 -0
  150. package/src/renderers/breadcrumb.ts +480 -0
  151. package/src/renderers/command-palette.ts +802 -0
  152. package/src/renderers/components/field.ts +210 -0
  153. package/src/renderers/components/form.ts +327 -0
  154. package/src/renderers/components/index.ts +21 -0
  155. package/src/renderers/components/search.ts +449 -0
  156. package/src/renderers/components/select.ts +222 -0
  157. package/src/renderers/index.ts +101 -0
  158. package/src/renderers/interactive/component-handlers.ts +622 -0
  159. package/src/renderers/interactive/cursor-manager.ts +147 -0
  160. package/src/renderers/interactive/focus-manager.ts +279 -0
  161. package/src/renderers/interactive/index.ts +661 -0
  162. package/src/renderers/interactive/input-handler.ts +164 -0
  163. package/src/renderers/interactive/keyboard-handler.ts +212 -0
  164. package/src/renderers/interactive/mouse-handler.ts +167 -0
  165. package/src/renderers/interactive/state-manager.ts +109 -0
  166. package/src/renderers/interactive/types.ts +338 -0
  167. package/src/renderers/interactive-string.ts +299 -0
  168. package/src/renderers/interactive.ts +59 -0
  169. package/src/renderers/markdown.ts +950 -0
  170. package/src/renderers/sidebar.ts +549 -0
  171. package/src/renderers/tabs.ts +682 -0
  172. package/src/renderers/text.ts +791 -0
  173. package/src/renderers/unicode.ts +917 -0
  174. package/src/renderers/utils.ts +942 -0
  175. package/src/router/adapters.ts +383 -0
  176. package/src/router/types.ts +140 -0
  177. package/src/router/utils.ts +452 -0
  178. package/src/schemas.ts +205 -0
  179. package/src/storybook/index.ts +91 -0
  180. package/src/storybook/interactive-decorator.tsx +659 -0
  181. package/src/storybook/keyboard-simulator.ts +501 -0
  182. package/src/theme/ansi-codes.ts +80 -0
  183. package/src/theme/box-drawing.ts +132 -0
  184. package/src/theme/color-convert.ts +254 -0
  185. package/src/theme/color-support.ts +321 -0
  186. package/src/theme/index.ts +134 -0
  187. package/src/theme/strip-ansi.ts +50 -0
  188. package/src/theme/tailwind-map.ts +469 -0
  189. package/src/theme/text-styles.ts +206 -0
  190. package/src/theme/theme-system.ts +568 -0
  191. package/src/types.ts +103 -0
@@ -0,0 +1,660 @@
1
+ /**
2
+ * @mdxui/terminal Error Handling Tests
3
+ *
4
+ * TDD RED Phase: These tests define the contract for proper error handling
5
+ * behavior throughout the terminal package. Tests verify:
6
+ * - Graceful handling of invalid inputs
7
+ * - Proper error throwing vs silent fallbacks
8
+ * - Edge case behavior for boundary conditions
9
+ *
10
+ * NOTE: Some tests define expected behavior that may need implementation fixes.
11
+ */
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
13
+ import React from 'react'
14
+
15
+ // ============================================================================
16
+ // Color Conversion Error Handling
17
+ // ============================================================================
18
+
19
+ describe('Error Handling', () => {
20
+ describe('Color Conversion', () => {
21
+ describe('hex()', () => {
22
+ it('throws ZodError for invalid hex string', async () => {
23
+ const { hex } = await import('@mdxui/terminal')
24
+ // With Zod validation, invalid hex throws instead of falling back
25
+ expect(() => hex('not-a-hex')).toThrow(/Invalid hex color format/)
26
+ })
27
+
28
+ it('throws ZodError for empty string', async () => {
29
+ const { hex } = await import('@mdxui/terminal')
30
+ // With Zod validation, empty string throws instead of falling back
31
+ expect(() => hex('')).toThrow(/Invalid hex color format/)
32
+ })
33
+
34
+ it('handles 3-digit hex correctly', async () => {
35
+ const { hex } = await import('@mdxui/terminal')
36
+ // #fff should expand to #ffffff (255, 255, 255)
37
+ const result = hex('#fff')
38
+ expect(result).toBe('\x1b[38;2;255;255;255m')
39
+ })
40
+
41
+ it('handles 6-digit hex correctly', async () => {
42
+ const { hex } = await import('@mdxui/terminal')
43
+ // #3b82f6 is Tailwind blue-500
44
+ const result = hex('#3b82f6')
45
+ expect(result).toBe('\x1b[38;2;59;130;246m')
46
+ })
47
+
48
+ it('handles hex without # prefix', async () => {
49
+ const { hex } = await import('@mdxui/terminal')
50
+ const result = hex('ff0000')
51
+ expect(result).toBe('\x1b[38;2;255;0;0m')
52
+ })
53
+
54
+ it('handles 3-digit hex without # prefix', async () => {
55
+ const { hex } = await import('@mdxui/terminal')
56
+ const result = hex('f00')
57
+ expect(result).toBe('\x1b[38;2;255;0;0m')
58
+ })
59
+ })
60
+
61
+ describe('hexToRgb()', () => {
62
+ it('returns black for invalid hex string', async () => {
63
+ const { hexToRgb } = await import('@mdxui/terminal')
64
+ const result = hexToRgb('invalid')
65
+ expect(result).toEqual({ r: 0, g: 0, b: 0 })
66
+ })
67
+
68
+ it('returns black for hex with wrong length', async () => {
69
+ const { hexToRgb } = await import('@mdxui/terminal')
70
+ expect(hexToRgb('#12')).toEqual({ r: 0, g: 0, b: 0 })
71
+ expect(hexToRgb('#1234')).toEqual({ r: 0, g: 0, b: 0 })
72
+ expect(hexToRgb('#12345')).toEqual({ r: 0, g: 0, b: 0 })
73
+ expect(hexToRgb('#1234567')).toEqual({ r: 0, g: 0, b: 0 })
74
+ })
75
+
76
+ it('returns black for empty string', async () => {
77
+ const { hexToRgb } = await import('@mdxui/terminal')
78
+ const result = hexToRgb('')
79
+ expect(result).toEqual({ r: 0, g: 0, b: 0 })
80
+ })
81
+
82
+ it('returns black for only hash prefix', async () => {
83
+ const { hexToRgb } = await import('@mdxui/terminal')
84
+ const result = hexToRgb('#')
85
+ expect(result).toEqual({ r: 0, g: 0, b: 0 })
86
+ })
87
+ })
88
+
89
+ describe('rgb() validation', () => {
90
+ it('accepts RGB values in valid range (0-255)', async () => {
91
+ const { rgb } = await import('@mdxui/terminal')
92
+ // Valid range should produce valid ANSI codes
93
+ expect(rgb(0, 0, 0)).toBe('\x1b[38;2;0;0;0m')
94
+ expect(rgb(255, 255, 255)).toBe('\x1b[38;2;255;255;255m')
95
+ expect(rgb(128, 128, 128)).toBe('\x1b[38;2;128;128;128m')
96
+ })
97
+
98
+ it('throws ZodError for values outside 0-255 range', async () => {
99
+ const { rgb } = await import('@mdxui/terminal')
100
+ // With Zod validation, out-of-range values throw instead of passing through
101
+ expect(() => rgb(-10, 0, 0)).toThrow(/RGB component must be >= 0/)
102
+ expect(() => rgb(300, 0, 0)).toThrow(/RGB component must be <= 255/)
103
+ })
104
+ })
105
+
106
+ describe('tailwindToTerminal()', () => {
107
+ it('handles unknown colors gracefully by returning empty string', async () => {
108
+ const { tailwindToTerminal } = await import('@mdxui/terminal')
109
+ expect(tailwindToTerminal('text-unknown-999')).toBe('')
110
+ expect(tailwindToTerminal('bg-nonexistent-500')).toBe('')
111
+ expect(tailwindToTerminal('random-class')).toBe('')
112
+ })
113
+
114
+ it('handles empty string gracefully', async () => {
115
+ const { tailwindToTerminal } = await import('@mdxui/terminal')
116
+ expect(tailwindToTerminal('')).toBe('')
117
+ })
118
+
119
+ it('handles null-like input gracefully', async () => {
120
+ const { tailwindToTerminal } = await import('@mdxui/terminal')
121
+ // The function should handle null/undefined gracefully
122
+ expect(tailwindToTerminal(null as unknown as string)).toBe('')
123
+ expect(tailwindToTerminal(undefined as unknown as string)).toBe('')
124
+ })
125
+
126
+ it('handles whitespace-only input', async () => {
127
+ const { tailwindToTerminal } = await import('@mdxui/terminal')
128
+ expect(tailwindToTerminal(' ')).toBe('')
129
+ })
130
+ })
131
+
132
+ describe('tailwindToAnsi()', () => {
133
+ it('returns empty string for unknown classes', async () => {
134
+ const { tailwindToAnsi } = await import('@mdxui/terminal')
135
+ expect(tailwindToAnsi('text-fake-color-999')).toBe('')
136
+ expect(tailwindToAnsi('completely-invalid')).toBe('')
137
+ })
138
+
139
+ it('returns empty string for empty input', async () => {
140
+ const { tailwindToAnsi } = await import('@mdxui/terminal')
141
+ expect(tailwindToAnsi('')).toBe('')
142
+ })
143
+ })
144
+ })
145
+
146
+ // ============================================================================
147
+ // Component Rendering Error Handling
148
+ // ============================================================================
149
+
150
+ describe('Component Rendering', () => {
151
+ describe('Box component', () => {
152
+ it('handles width of 0 gracefully', async () => {
153
+ const { renderComponent, Box } = await import('@mdxui/terminal')
154
+ const element = React.createElement(Box, { width: 0, children: 'test' })
155
+ // Should not throw, should return some output (possibly empty or minimal)
156
+ expect(() => renderComponent(element)).not.toThrow()
157
+ })
158
+
159
+ it('handles negative width gracefully', async () => {
160
+ const { renderComponent, Box } = await import('@mdxui/terminal')
161
+ const element = React.createElement(Box, { width: -10, children: 'test' })
162
+ // Should not throw - implementation should handle gracefully
163
+ expect(() => renderComponent(element)).not.toThrow()
164
+ })
165
+
166
+ it('handles undefined children gracefully', async () => {
167
+ const { renderComponent, Box } = await import('@mdxui/terminal')
168
+ const element = React.createElement(Box, { width: 20 })
169
+ const result = renderComponent(element)
170
+ expect(Array.isArray(result)).toBe(true)
171
+ })
172
+
173
+ it('handles null children gracefully', async () => {
174
+ const { renderComponent, Box } = await import('@mdxui/terminal')
175
+ const element = React.createElement(Box, { width: 20, children: null })
176
+ const result = renderComponent(element)
177
+ expect(Array.isArray(result)).toBe(true)
178
+ })
179
+ })
180
+
181
+ describe('Table component', () => {
182
+ it('renders empty array for empty data', async () => {
183
+ const { renderComponent, Table } = await import('@mdxui/terminal')
184
+ const element = React.createElement(Table, {
185
+ data: [],
186
+ columns: [{ key: 'name', header: 'Name' }],
187
+ })
188
+ const result = renderComponent(element)
189
+ expect(Array.isArray(result)).toBe(true)
190
+ // Should have at least the header row
191
+ expect(result.length).toBeGreaterThanOrEqual(1)
192
+ })
193
+
194
+ it('handles empty columns array', async () => {
195
+ const { renderComponent, Table } = await import('@mdxui/terminal')
196
+ const element = React.createElement(Table, {
197
+ data: [{ name: 'test' }],
198
+ columns: [],
199
+ })
200
+ // Should not throw with empty columns
201
+ expect(() => renderComponent(element)).not.toThrow()
202
+ })
203
+
204
+ it('handles missing column key in data', async () => {
205
+ const { renderComponent, Table } = await import('@mdxui/terminal')
206
+ const element = React.createElement(Table, {
207
+ data: [{ name: 'test' }],
208
+ columns: [{ key: 'nonexistent', header: 'Missing' }],
209
+ })
210
+ // Should render without throwing, showing empty/undefined values
211
+ expect(() => renderComponent(element)).not.toThrow()
212
+ })
213
+ })
214
+
215
+ describe('renderComponent()', () => {
216
+ it('throws when given null element', async () => {
217
+ const { renderComponent } = await import('@mdxui/terminal')
218
+ // @ts-expect-error Testing invalid input
219
+ // Current implementation throws on null - does not handle gracefully
220
+ expect(() => renderComponent(null)).toThrow()
221
+ })
222
+
223
+ it('handles unknown component type gracefully', async () => {
224
+ const { renderComponent } = await import('@mdxui/terminal')
225
+ // Creating an element with an unknown type string
226
+ const unknownElement = React.createElement('unknown-component', {
227
+ children: 'content',
228
+ })
229
+ const result = renderComponent(unknownElement)
230
+ // Should return empty array or the children text for unknown types
231
+ expect(Array.isArray(result)).toBe(true)
232
+ })
233
+
234
+ it('handles deeply nested invalid elements', async () => {
235
+ const { renderComponent, Box } = await import('@mdxui/terminal')
236
+ const element = React.createElement(Box, {
237
+ children: React.createElement('invalid', {
238
+ children: React.createElement('also-invalid', { children: 'text' }),
239
+ }),
240
+ })
241
+ expect(() => renderComponent(element)).not.toThrow()
242
+ })
243
+ })
244
+ })
245
+
246
+ // ============================================================================
247
+ // Keyboard Manager Error Handling
248
+ // ============================================================================
249
+
250
+ describe('Keyboard Manager', () => {
251
+ describe('binding validation', () => {
252
+ it('handles empty bindings object', async () => {
253
+ const { createKeyboardManager } = await import('@mdxui/terminal')
254
+ const manager = createKeyboardManager({ bindings: {} })
255
+ expect(manager.getAction('anything')).toBeUndefined()
256
+ expect(manager.handleKey('anything')).toBe(false)
257
+ })
258
+
259
+ it('handles binding with empty string key', async () => {
260
+ const { createKeyboardManager } = await import('@mdxui/terminal')
261
+ const manager = createKeyboardManager({
262
+ bindings: { '': 'empty-key-action' },
263
+ })
264
+ // Empty string key behavior - should be retrievable
265
+ expect(manager.getAction('')).toBe('empty-key-action')
266
+ })
267
+ })
268
+
269
+ describe('destroyed manager behavior', () => {
270
+ it('clears pending sequence on destroy', async () => {
271
+ const { createKeyboardManager } = await import('@mdxui/terminal')
272
+ const manager = createKeyboardManager({
273
+ bindings: { gg: 'move-first' },
274
+ })
275
+
276
+ manager.handleKey('g')
277
+ expect(manager.getPendingSequence()).toBe('g')
278
+
279
+ manager.destroy()
280
+ expect(manager.getPendingSequence()).toBe('')
281
+ })
282
+
283
+ it('can be called multiple times safely', async () => {
284
+ const { createKeyboardManager } = await import('@mdxui/terminal')
285
+ const manager = createKeyboardManager({
286
+ bindings: { gg: 'move-first' },
287
+ })
288
+
289
+ manager.handleKey('g')
290
+ expect(() => {
291
+ manager.destroy()
292
+ manager.destroy()
293
+ manager.destroy()
294
+ }).not.toThrow()
295
+ })
296
+
297
+ it('handles calls after destroy without crashing', async () => {
298
+ const { createKeyboardManager } = await import('@mdxui/terminal')
299
+ const onAction = vi.fn()
300
+ const manager = createKeyboardManager({
301
+ bindings: { j: 'move-down' },
302
+ onAction,
303
+ })
304
+
305
+ manager.destroy()
306
+
307
+ // After destroy, the manager should still be usable but in disabled state
308
+ // or handle calls gracefully
309
+ expect(() => manager.handleKey('j')).not.toThrow()
310
+ expect(() => manager.getAction('j')).not.toThrow()
311
+ expect(() => manager.addBinding('k', 'move-up')).not.toThrow()
312
+ })
313
+ })
314
+
315
+ describe('sequence timeout behavior', () => {
316
+ it('sequence times out and triggers fallback action', async () => {
317
+ const { createKeyboardManager } = await import('@mdxui/terminal')
318
+ const onAction = vi.fn()
319
+
320
+ const manager = createKeyboardManager({
321
+ bindings: {
322
+ gg: 'move-first',
323
+ g: 'go', // Fallback if only single g
324
+ },
325
+ onAction,
326
+ sequenceTimeout: 100,
327
+ })
328
+
329
+ manager.handleKey('g')
330
+
331
+ // Wait for timeout to fire
332
+ await new Promise((r) => setTimeout(r, 150))
333
+
334
+ // Should have called the fallback 'g' action after timeout
335
+ expect(onAction).toHaveBeenCalledWith('go', { key: 'g' })
336
+ })
337
+
338
+ it('sequence timeout does nothing when no fallback exists', async () => {
339
+ const { createKeyboardManager } = await import('@mdxui/terminal')
340
+ const onAction = vi.fn()
341
+
342
+ const manager = createKeyboardManager({
343
+ bindings: {
344
+ gg: 'move-first',
345
+ // No fallback for single 'g'
346
+ },
347
+ onAction,
348
+ sequenceTimeout: 100,
349
+ })
350
+
351
+ manager.handleKey('g')
352
+ await new Promise((r) => setTimeout(r, 150))
353
+
354
+ // Should not have called any action since 'g' alone has no binding
355
+ expect(onAction).not.toHaveBeenCalled()
356
+ })
357
+
358
+ it('destroy clears pending sequence timer to prevent memory leak', async () => {
359
+ const { createKeyboardManager } = await import('@mdxui/terminal')
360
+ const onAction = vi.fn()
361
+
362
+ const manager = createKeyboardManager({
363
+ bindings: {
364
+ gg: 'move-first',
365
+ g: 'go',
366
+ },
367
+ onAction,
368
+ sequenceTimeout: 100,
369
+ })
370
+
371
+ // Start a sequence which creates a timer
372
+ manager.handleKey('g')
373
+
374
+ // Immediately destroy before timeout fires
375
+ manager.destroy()
376
+
377
+ const callCountBeforeWait = onAction.mock.calls.length
378
+
379
+ // Wait for what would have been the timeout
380
+ await new Promise((r) => setTimeout(r, 200))
381
+
382
+ // The timeout handler should NOT have been called because destroy cleared it
383
+ expect(onAction.mock.calls.length).toBe(callCountBeforeWait)
384
+ })
385
+ })
386
+
387
+ describe('disabled manager', () => {
388
+ it('ignores key handling when disabled', async () => {
389
+ const { createKeyboardManager } = await import('@mdxui/terminal')
390
+ const onAction = vi.fn()
391
+
392
+ const manager = createKeyboardManager({
393
+ bindings: { j: 'move-down' },
394
+ onAction,
395
+ enabled: false,
396
+ })
397
+
398
+ const result = manager.handleKey('j')
399
+ expect(result).toBe(false)
400
+ expect(onAction).not.toHaveBeenCalled()
401
+ })
402
+
403
+ it('enable/disable toggles work correctly', async () => {
404
+ const { createKeyboardManager } = await import('@mdxui/terminal')
405
+ const onAction = vi.fn()
406
+
407
+ const manager = createKeyboardManager({
408
+ bindings: { j: 'move-down' },
409
+ onAction,
410
+ })
411
+
412
+ manager.disable()
413
+ manager.handleKey('j')
414
+ expect(onAction).not.toHaveBeenCalled()
415
+
416
+ manager.enable()
417
+ manager.handleKey('j')
418
+ expect(onAction).toHaveBeenCalledWith('move-down', { key: 'j' })
419
+ })
420
+ })
421
+ })
422
+
423
+ // ============================================================================
424
+ // Theme System Error Handling
425
+ // ============================================================================
426
+
427
+ describe('Theme System', () => {
428
+ describe('createTheme()', () => {
429
+ it('throws ZodError for invalid ANSI color values', async () => {
430
+ const { createTheme } = await import('@mdxui/terminal')
431
+ // With Zod validation, the error message comes from the schema
432
+ expect(() =>
433
+ createTheme({
434
+ primary: 'not-an-ansi-code',
435
+ })
436
+ ).toThrow(/Invalid ANSI escape sequence/)
437
+ })
438
+
439
+ it('accepts empty string as valid color (no color)', async () => {
440
+ const { createTheme } = await import('@mdxui/terminal')
441
+ expect(() =>
442
+ createTheme({
443
+ background: '',
444
+ })
445
+ ).not.toThrow()
446
+ })
447
+
448
+ it('accepts valid ANSI codes', async () => {
449
+ const { createTheme } = await import('@mdxui/terminal')
450
+ expect(() =>
451
+ createTheme({
452
+ primary: '\x1b[34m',
453
+ secondary: '\x1b[38;5;45m',
454
+ })
455
+ ).not.toThrow()
456
+ })
457
+
458
+ it('creates theme with defaults when no options provided', async () => {
459
+ const { createTerminalTheme } = await import('@mdxui/terminal')
460
+ const theme = createTerminalTheme({})
461
+ expect(theme.mode).toBe('dark')
462
+ expect(theme.colors).toBeDefined()
463
+ expect(theme.colors.primary).toBeDefined()
464
+ })
465
+ })
466
+
467
+ describe('extendTheme()', () => {
468
+ it('merges partial themes safely without modifying original', async () => {
469
+ const { extendTheme, defaultTheme } = await import('@mdxui/terminal')
470
+
471
+ const originalPrimary = defaultTheme.primary
472
+ const extended = extendTheme(defaultTheme, {
473
+ primary: '\x1b[35m',
474
+ })
475
+
476
+ // Original should not be modified
477
+ expect(defaultTheme.primary).toBe(originalPrimary)
478
+ // Extended should have new value
479
+ expect(extended.primary).toBe('\x1b[35m')
480
+ })
481
+
482
+ it('handles empty override object', async () => {
483
+ const { extendTheme, defaultTheme } = await import('@mdxui/terminal')
484
+ const extended = extendTheme(defaultTheme, {})
485
+ expect(extended).toEqual(defaultTheme)
486
+ })
487
+
488
+ it('deep merges nested objects like typography and spacing', async () => {
489
+ const { extendTheme, defaultTheme } = await import('@mdxui/terminal')
490
+ const extended = extendTheme(defaultTheme, {
491
+ typography: { headingWeight: 'normal' },
492
+ })
493
+
494
+ // Should have merged the typography object
495
+ expect(extended.typography?.headingWeight).toBe('normal')
496
+ // Other typography properties should be preserved
497
+ expect(extended.typography?.bodyWeight).toBe('normal')
498
+ expect(extended.typography?.codeFont).toBe('monospace')
499
+ })
500
+ })
501
+
502
+ describe('getThemeColor()', () => {
503
+ it('throws when unknown key and no fallback provided', async () => {
504
+ const { getThemeColor, createTerminalTheme } = await import('@mdxui/terminal')
505
+ const theme = createTerminalTheme({ mode: 'dark' })
506
+
507
+ expect(() =>
508
+ getThemeColor(theme, 'nonexistent' as unknown as 'primary')
509
+ ).toThrow()
510
+ })
511
+
512
+ it('returns fallback for unknown key when provided', async () => {
513
+ const { getThemeColor, createTerminalTheme } = await import('@mdxui/terminal')
514
+ const theme = createTerminalTheme({ mode: 'dark' })
515
+ const fallback = '\x1b[37m'
516
+
517
+ const color = getThemeColor(
518
+ theme,
519
+ 'nonexistent' as unknown as 'primary',
520
+ fallback
521
+ )
522
+ expect(color).toBe(fallback)
523
+ })
524
+ })
525
+
526
+ describe('degradeColor()', () => {
527
+ it('returns empty string for support level none', async () => {
528
+ const { degradeColor } = await import('@mdxui/terminal')
529
+ const color = '\x1b[38;5;33m'
530
+ expect(degradeColor(color, 'none')).toBe('')
531
+ })
532
+
533
+ it('passes through valid color when support matches', async () => {
534
+ const { degradeColor } = await import('@mdxui/terminal')
535
+ const color256 = '\x1b[38;5;33m'
536
+ expect(degradeColor(color256, '256')).toBe(color256)
537
+ })
538
+
539
+ it('handles empty string input', async () => {
540
+ const { degradeColor } = await import('@mdxui/terminal')
541
+ expect(degradeColor('', '256')).toBe('')
542
+ })
543
+
544
+ it('handles invalid ANSI code gracefully', async () => {
545
+ const { degradeColor } = await import('@mdxui/terminal')
546
+ // Invalid ANSI should pass through unchanged
547
+ expect(degradeColor('not-ansi', '256')).toBe('not-ansi')
548
+ })
549
+
550
+ it('converts truecolor to 256 correctly', async () => {
551
+ const { degradeColor } = await import('@mdxui/terminal')
552
+ const truecolor = '\x1b[38;2;59;130;246m' // RGB for blue-500
553
+ const degraded = degradeColor(truecolor, '256')
554
+ // Should be a valid 256-color code
555
+ expect(degraded).toMatch(/\x1b\[38;5;\d+m/)
556
+ })
557
+
558
+ it('converts 256 to 16 color correctly', async () => {
559
+ const { degradeColor } = await import('@mdxui/terminal')
560
+ const color256 = '\x1b[38;5;33m' // Blue in 256 palette
561
+ const degraded = degradeColor(color256, '16')
562
+ // Should be a basic 16-color code (30-37 or 90-97)
563
+ expect(degraded).toMatch(/\x1b\[(3[0-7]|9[0-7])m/)
564
+ })
565
+ })
566
+
567
+ describe('cssVarToAnsi()', () => {
568
+ it('returns empty string for unknown CSS variables', async () => {
569
+ const { cssVarToAnsi, createTerminalTheme } = await import('@mdxui/terminal')
570
+ const theme = createTerminalTheme({ mode: 'dark' })
571
+ expect(cssVarToAnsi('--unknown-variable', theme)).toBe('')
572
+ expect(cssVarToAnsi('not-a-css-var', theme)).toBe('')
573
+ })
574
+
575
+ it('handles empty string CSS variable', async () => {
576
+ const { cssVarToAnsi, createTerminalTheme } = await import('@mdxui/terminal')
577
+ const theme = createTerminalTheme({ mode: 'dark' })
578
+ expect(cssVarToAnsi('', theme)).toBe('')
579
+ })
580
+ })
581
+ })
582
+
583
+ // ============================================================================
584
+ // Input Normalization Edge Cases
585
+ // ============================================================================
586
+
587
+ describe('Input Normalization', () => {
588
+ describe('normalizeReadlineKey()', () => {
589
+ it('handles undefined string and key', async () => {
590
+ const { normalizeReadlineKey } = await import('@mdxui/terminal')
591
+ const result = normalizeReadlineKey(undefined, undefined)
592
+ expect(result.name).toBe('')
593
+ expect(result.ctrl).toBe(false)
594
+ expect(result.alt).toBe(false)
595
+ expect(result.shift).toBe(false)
596
+ expect(result.meta).toBe(false)
597
+ })
598
+
599
+ it('handles empty string input', async () => {
600
+ const { normalizeReadlineKey } = await import('@mdxui/terminal')
601
+ const result = normalizeReadlineKey('', undefined)
602
+ expect(result.name).toBe('')
603
+ })
604
+ })
605
+
606
+ describe('keyToBindingString()', () => {
607
+ it('handles key with no modifiers', async () => {
608
+ const { keyToBindingString } = await import('@mdxui/terminal')
609
+ const result = keyToBindingString({
610
+ name: 'a',
611
+ ctrl: false,
612
+ alt: false,
613
+ shift: false,
614
+ meta: false,
615
+ })
616
+ expect(result).toBe('a')
617
+ })
618
+
619
+ it('handles empty key name', async () => {
620
+ const { keyToBindingString } = await import('@mdxui/terminal')
621
+ const result = keyToBindingString({
622
+ name: '',
623
+ ctrl: false,
624
+ alt: false,
625
+ shift: false,
626
+ meta: false,
627
+ })
628
+ expect(result).toBe('')
629
+ })
630
+
631
+ it('handles empty key name with modifiers', async () => {
632
+ const { keyToBindingString } = await import('@mdxui/terminal')
633
+ const result = keyToBindingString({
634
+ name: '',
635
+ ctrl: true,
636
+ alt: false,
637
+ shift: false,
638
+ meta: false,
639
+ })
640
+ // Should produce 'ctrl+' with empty key
641
+ expect(result).toBe('ctrl+')
642
+ })
643
+ })
644
+
645
+ describe('attachKeyboardManager()', () => {
646
+ it('throws error for non-TTY stdin', async () => {
647
+ const { createKeyboardManager, attachKeyboardManager } = await import('@mdxui/terminal')
648
+ const manager = createKeyboardManager({ bindings: {} })
649
+
650
+ const mockStream = {
651
+ isTTY: false,
652
+ } as NodeJS.ReadStream
653
+
654
+ expect(() => attachKeyboardManager(manager, { input: mockStream })).toThrow(
655
+ /requires a TTY stdin/
656
+ )
657
+ })
658
+ })
659
+ })
660
+ })