@gustavobrunodev/ai-tools 1.0.1 → 1.1.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 (106) hide show
  1. package/CHANGELOG.md +0 -0
  2. package/jest.config.ts +26 -0
  3. package/package.json +1 -1
  4. package/project.json +74 -0
  5. package/src/app.tsx +56 -0
  6. package/src/atoms/deprecatedSkills.ts +11 -0
  7. package/src/atoms/environmentCheck.ts +51 -0
  8. package/src/atoms/installedSkills.ts +36 -0
  9. package/src/atoms/wizard.ts +6 -0
  10. package/src/cli/audit.ts +21 -0
  11. package/src/cli/cache.ts +28 -0
  12. package/src/cli/install.ts +93 -0
  13. package/src/cli/list.ts +41 -0
  14. package/src/cli/remove.ts +70 -0
  15. package/src/cli/update.ts +107 -0
  16. package/src/components/AnimatedTransition.tsx +42 -0
  17. package/src/components/AuditLogViewer.tsx +85 -0
  18. package/src/components/CategoryHeader.tsx +39 -0
  19. package/src/components/ConfirmPrompt.tsx +34 -0
  20. package/src/components/FooterBar.tsx +36 -0
  21. package/src/components/Header.tsx +97 -0
  22. package/src/components/InstallResults.tsx +110 -0
  23. package/src/components/KeyboardShortcutsOverlay.tsx +112 -0
  24. package/src/components/MultiSelectPrompt.tsx +219 -0
  25. package/src/components/SearchInput.tsx +36 -0
  26. package/src/components/SelectPrompt.tsx +108 -0
  27. package/src/components/SkillCard.tsx +74 -0
  28. package/src/components/SkillDetailPanel.tsx +233 -0
  29. package/src/components/StatusBadge.tsx +45 -0
  30. package/src/components/__tests__/AnimatedTransition.pbt.test.tsx +51 -0
  31. package/src/components/__tests__/CategoryHeader.pbt.test.tsx +107 -0
  32. package/src/components/__tests__/CategoryHeader.test.tsx +105 -0
  33. package/src/components/__tests__/KeyboardShortcutsOverlay.pbt.test.tsx +155 -0
  34. package/src/components/__tests__/KeyboardShortcutsOverlay.test.tsx +136 -0
  35. package/src/components/__tests__/SkillDetailPanel.test.tsx +273 -0
  36. package/src/components/index.ts +12 -0
  37. package/src/hooks/__tests__/useConfig.test.ts +242 -0
  38. package/src/hooks/index.ts +9 -0
  39. package/src/hooks/useAgents.ts +28 -0
  40. package/src/hooks/useConfig.ts +114 -0
  41. package/src/hooks/useFilter.ts +31 -0
  42. package/src/hooks/useInstaller.ts +39 -0
  43. package/src/hooks/useKeyboardNav.ts +39 -0
  44. package/src/hooks/useKonamiCode.ts +48 -0
  45. package/src/hooks/useRemover.ts +59 -0
  46. package/src/hooks/useSkillContent.ts +67 -0
  47. package/src/hooks/useSkills.ts +38 -0
  48. package/src/hooks/useWizardStep.ts +19 -0
  49. package/src/index.ts +129 -0
  50. package/src/services/__tests__/audit-log.spec.ts +220 -0
  51. package/src/services/__tests__/badge-format.test.ts +102 -0
  52. package/src/services/__tests__/category-colors.test.ts +253 -0
  53. package/src/services/__tests__/config.test.ts +184 -0
  54. package/src/services/__tests__/installer.security.spec.ts +151 -0
  55. package/src/services/__tests__/lockfile.security.spec.ts +132 -0
  56. package/src/services/__tests__/markdown-parser.spec.ts +185 -0
  57. package/src/services/__tests__/terminal-dimensions.pbt.test.ts +246 -0
  58. package/src/services/__tests__/terminal-dimensions.test.ts +109 -0
  59. package/src/services/__tests__/update-cache.pbt.test.ts +214 -0
  60. package/src/services/__tests__/update-cache.test.ts +215 -0
  61. package/src/services/agents.ts +42 -0
  62. package/src/services/audio-player.ts +55 -0
  63. package/src/services/audit-log.ts +69 -0
  64. package/src/services/badge-format.ts +4 -0
  65. package/src/services/categories.ts +176 -0
  66. package/src/services/category-colors.ts +19 -0
  67. package/src/services/config.ts +84 -0
  68. package/src/services/github-contributors.ts +56 -0
  69. package/src/services/global-path.ts +20 -0
  70. package/src/services/index.ts +21 -0
  71. package/src/services/installer.ts +371 -0
  72. package/src/services/lockfile.ts +177 -0
  73. package/src/services/markdown-parser.ts +108 -0
  74. package/src/services/package-info.ts +19 -0
  75. package/src/services/project-root.ts +18 -0
  76. package/src/services/registry.ts +382 -0
  77. package/src/services/skills-provider.ts +169 -0
  78. package/src/services/terminal-dimensions.ts +18 -0
  79. package/src/services/update-cache.ts +65 -0
  80. package/src/services/update-check.ts +26 -0
  81. package/src/theme/colors.ts +24 -0
  82. package/src/theme/index.ts +2 -0
  83. package/src/theme/symbols.ts +22 -0
  84. package/src/types.ts +38 -0
  85. package/src/utils/constants.ts +49 -0
  86. package/src/utils/paths.ts +52 -0
  87. package/src/views/ActionSelector.tsx +45 -0
  88. package/src/views/AgentSelector.tsx +105 -0
  89. package/src/views/CreditsView.tsx +332 -0
  90. package/src/views/InstallConfig.tsx +162 -0
  91. package/src/views/InstallWizard.tsx +181 -0
  92. package/src/views/ListView.tsx +41 -0
  93. package/src/views/RemoveWizard.tsx +237 -0
  94. package/src/views/SkillBrowser.tsx +504 -0
  95. package/src/views/UpdateView.tsx +272 -0
  96. package/src/views/arcade/ArcadeMenu.tsx +89 -0
  97. package/src/views/arcade/VibeInvaders.tsx +339 -0
  98. package/src/views/arcade/index.ts +2 -0
  99. package/src/views/index.ts +11 -0
  100. package/tsconfig.json +19 -0
  101. package/tsconfig.spec.json +25 -0
  102. package/LICENSE +0 -26
  103. package/README.md +0 -257
  104. package/index.js +0 -12
  105. package/index.js.map +0 -7
  106. /package/{assets → src/assets}/chiptune.mp3 +0 -0
@@ -0,0 +1,132 @@
1
+ import { mkdir, rm, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { tmpdir } from 'node:os'
4
+ import { readSkillLock, removeSkillFromLock, addSkillToLock } from '../lockfile'
5
+ import type { AgentType } from '../../types'
6
+
7
+ describe('Lockfile Security', () => {
8
+ let testDir: string
9
+ let originalCwd: string
10
+
11
+ beforeEach(async () => {
12
+ originalCwd = process.cwd()
13
+ testDir = join(tmpdir(), `lockfile-test-${Date.now()}`)
14
+ await mkdir(testDir, { recursive: true })
15
+ process.chdir(testDir)
16
+ })
17
+
18
+ afterEach(async () => {
19
+ process.chdir(originalCwd)
20
+ await rm(testDir, { recursive: true, force: true }).catch(() => {})
21
+ })
22
+
23
+ describe('Lockfile Integrity', () => {
24
+ it('should handle corrupted lockfile gracefully', async () => {
25
+ const agentsDir = join(testDir, '.agents')
26
+ await mkdir(agentsDir, { recursive: true })
27
+ await writeFile(join(agentsDir, '.skill-lock.json'), 'invalid json{{{')
28
+
29
+ const lock = await readSkillLock(false)
30
+ expect(lock.version).toBe(2)
31
+ expect(lock.skills).toEqual({})
32
+ })
33
+
34
+ it('should create backup before writing', async () => {
35
+ const agents: AgentType[] = ['github-copilot']
36
+ await addSkillToLock('test-skill', agents, { source: 'local' })
37
+
38
+ // Write again to trigger backup
39
+ await addSkillToLock('test-skill-2', agents, { source: 'local' })
40
+
41
+ // Backup should exist (we can't easily verify without exposing internal paths)
42
+ const lock = await readSkillLock(false)
43
+ expect(lock.skills['test-skill']).toBeDefined()
44
+ expect(lock.skills['test-skill-2']).toBeDefined()
45
+ })
46
+
47
+ it('should use atomic writes', async () => {
48
+ const agents: AgentType[] = ['github-copilot']
49
+
50
+ // Write sequentially to avoid race conditions in test
51
+ await addSkillToLock('skill-1', agents, { source: 'local' })
52
+ await addSkillToLock('skill-2', agents, { source: 'local' })
53
+ await addSkillToLock('skill-3', agents, { source: 'local' })
54
+
55
+ const lock = await readSkillLock(false)
56
+ expect(Object.keys(lock.skills).length).toBe(3)
57
+ })
58
+ })
59
+
60
+ describe('Lockfile Migration', () => {
61
+ it('should migrate v1 lockfile to v2', async () => {
62
+ const agentsDir = join(testDir, '.agents')
63
+ await mkdir(agentsDir, { recursive: true })
64
+
65
+ const v1Lock = {
66
+ version: 1,
67
+ skills: {
68
+ 'old-skill': {
69
+ name: 'old-skill',
70
+ source: 'local',
71
+ installedAt: new Date().toISOString(),
72
+ updatedAt: new Date().toISOString(),
73
+ },
74
+ },
75
+ }
76
+
77
+ await writeFile(join(agentsDir, '.skill-lock.json'), JSON.stringify(v1Lock))
78
+
79
+ const lock = await readSkillLock(false)
80
+ expect(lock.version).toBe(2)
81
+ expect(lock.skills['old-skill']).toBeDefined()
82
+ expect(lock.skills['old-skill'].method).toBe('copy')
83
+ expect(lock.skills['old-skill'].global).toBe(false)
84
+ })
85
+ })
86
+
87
+ describe('Lockfile Operations', () => {
88
+ it('should prevent duplicate entries', async () => {
89
+ const agents: AgentType[] = ['github-copilot']
90
+
91
+ await addSkillToLock('test-skill', agents, { source: 'local' })
92
+ await addSkillToLock('test-skill', agents, { source: 'local' })
93
+
94
+ const lock = await readSkillLock(false)
95
+ expect(Object.keys(lock.skills)).toHaveLength(1)
96
+ })
97
+
98
+ it('should update timestamps on re-add', async () => {
99
+ const agents: AgentType[] = ['github-copilot']
100
+
101
+ await addSkillToLock('test-skill', agents, { source: 'local' })
102
+ const lock1 = await readSkillLock(false)
103
+ const firstUpdate = lock1.skills['test-skill'].updatedAt
104
+
105
+ // Wait a bit
106
+ await new Promise((resolve) => setTimeout(resolve, 10))
107
+
108
+ await addSkillToLock('test-skill', agents, { source: 'local' })
109
+ const lock2 = await readSkillLock(false)
110
+ const secondUpdate = lock2.skills['test-skill'].updatedAt
111
+
112
+ expect(secondUpdate).not.toBe(firstUpdate)
113
+ })
114
+
115
+ it('should remove skills correctly', async () => {
116
+ const agents: AgentType[] = ['github-copilot']
117
+
118
+ await addSkillToLock('test-skill', agents, { source: 'local' })
119
+ const removed = await removeSkillFromLock('test-skill', false)
120
+
121
+ expect(removed).toBe(true)
122
+
123
+ const lock = await readSkillLock(false)
124
+ expect(lock.skills['test-skill']).toBeUndefined()
125
+ })
126
+
127
+ it('should return false when removing non-existent skill', async () => {
128
+ const removed = await removeSkillFromLock('non-existent', false)
129
+ expect(removed).toBe(false)
130
+ })
131
+ })
132
+ })
@@ -0,0 +1,185 @@
1
+ import { parseMarkdown, parseInline } from '../markdown-parser'
2
+
3
+ describe('parseMarkdown', () => {
4
+ it('strips YAML frontmatter', () => {
5
+ const input = `---
6
+ name: test-skill
7
+ description: A test
8
+ ---
9
+
10
+ # Title`
11
+
12
+ const tokens = parseMarkdown(input)
13
+ expect(tokens[0]).toEqual({ type: 'heading', level: 1, text: 'Title' })
14
+ })
15
+
16
+ it('parses headings at levels 1-3', () => {
17
+ const tokens = parseMarkdown('# H1\n## H2\n### H3')
18
+ expect(tokens).toEqual([
19
+ { type: 'heading', level: 1, text: 'H1' },
20
+ { type: 'heading', level: 2, text: 'H2' },
21
+ { type: 'heading', level: 3, text: 'H3' },
22
+ ])
23
+ })
24
+
25
+ it('parses unordered list items with - and *', () => {
26
+ const tokens = parseMarkdown('- first\n* second')
27
+ expect(tokens).toEqual([
28
+ { type: 'list-item', text: 'first', indent: 0 },
29
+ { type: 'list-item', text: 'second', indent: 0 },
30
+ ])
31
+ })
32
+
33
+ it('detects nested list item indentation', () => {
34
+ const tokens = parseMarkdown('- top\n - nested\n - deep')
35
+ expect(tokens).toEqual([
36
+ { type: 'list-item', text: 'top', indent: 0 },
37
+ { type: 'list-item', text: 'nested', indent: 1 },
38
+ { type: 'list-item', text: 'deep', indent: 2 },
39
+ ])
40
+ })
41
+
42
+ it('parses numbered list items', () => {
43
+ const tokens = parseMarkdown('1. first\n2. second')
44
+ expect(tokens).toEqual([
45
+ { type: 'list-item', text: 'first', indent: 0 },
46
+ { type: 'list-item', text: 'second', indent: 0 },
47
+ ])
48
+ })
49
+
50
+ it('parses fenced code blocks with language', () => {
51
+ const input = '```typescript\nconst x = 1\nconsole.log(x)\n```'
52
+ const tokens = parseMarkdown(input)
53
+ expect(tokens).toEqual([
54
+ { type: 'code-block', language: 'typescript', lines: ['const x = 1', 'console.log(x)'] },
55
+ ])
56
+ })
57
+
58
+ it('parses code blocks without language', () => {
59
+ const tokens = parseMarkdown('```\nhello\n```')
60
+ expect(tokens).toEqual([
61
+ { type: 'code-block', language: '', lines: ['hello'] },
62
+ ])
63
+ })
64
+
65
+ it('parses horizontal rules', () => {
66
+ const tokens = parseMarkdown('---')
67
+ expect(tokens).toEqual([{ type: 'hr' }])
68
+ })
69
+
70
+ it('treats blank lines as blank tokens', () => {
71
+ const tokens = parseMarkdown('text\n\nmore')
72
+ expect(tokens).toEqual([
73
+ { type: 'paragraph', text: 'text' },
74
+ { type: 'blank' },
75
+ { type: 'paragraph', text: 'more' },
76
+ ])
77
+ })
78
+
79
+ it('treats plain text as paragraphs', () => {
80
+ const tokens = parseMarkdown('Hello world')
81
+ expect(tokens).toEqual([{ type: 'paragraph', text: 'Hello world' }])
82
+ })
83
+
84
+ it('handles a realistic SKILL.md structure', () => {
85
+ const input = `---
86
+ name: test
87
+ ---
88
+
89
+ # My Skill
90
+
91
+ A description paragraph.
92
+
93
+ ## Usage
94
+
95
+ - Step one
96
+ - Step two
97
+
98
+ \`\`\`bash
99
+ npm install
100
+ \`\`\`
101
+ `
102
+ const tokens = parseMarkdown(input)
103
+ const types = tokens.map((t) => t.type)
104
+ expect(types).toContain('heading')
105
+ expect(types).toContain('paragraph')
106
+ expect(types).toContain('list-item')
107
+ expect(types).toContain('code-block')
108
+ })
109
+
110
+ // Property: every non-empty line produces a token
111
+ it('produces at least one token for any non-empty input', () => {
112
+ const inputs = ['hello', '# heading', '- item', '```\ncode\n```', '---']
113
+ for (const input of inputs) {
114
+ const tokens = parseMarkdown(input)
115
+ expect(tokens.length).toBeGreaterThan(0)
116
+ }
117
+ })
118
+
119
+ // Property: no frontmatter tokens leak through
120
+ it('never produces tokens containing frontmatter delimiters', () => {
121
+ const input = '---\nkey: value\n---\n# Title'
122
+ const tokens = parseMarkdown(input)
123
+ for (const token of tokens) {
124
+ if ('text' in token) {
125
+ expect(token.text).not.toMatch(/^---$/)
126
+ expect(token.text).not.toMatch(/^key: value$/)
127
+ }
128
+ }
129
+ })
130
+ })
131
+
132
+ describe('parseInline', () => {
133
+ it('returns plain text as single segment', () => {
134
+ expect(parseInline('hello')).toEqual([{ text: 'hello' }])
135
+ })
136
+
137
+ it('parses bold text', () => {
138
+ const segments = parseInline('this is **bold** text')
139
+ expect(segments).toEqual([
140
+ { text: 'this is ' },
141
+ { text: 'bold', bold: true },
142
+ { text: ' text' },
143
+ ])
144
+ })
145
+
146
+ it('parses italic text', () => {
147
+ const segments = parseInline('this is *italic* text')
148
+ expect(segments).toEqual([
149
+ { text: 'this is ' },
150
+ { text: 'italic', italic: true },
151
+ { text: ' text' },
152
+ ])
153
+ })
154
+
155
+ it('parses inline code', () => {
156
+ const segments = parseInline('use `npm install` here')
157
+ expect(segments).toEqual([
158
+ { text: 'use ' },
159
+ { text: 'npm install', code: true },
160
+ { text: ' here' },
161
+ ])
162
+ })
163
+
164
+ it('handles mixed inline formatting', () => {
165
+ const segments = parseInline('**bold** and `code` and *italic*')
166
+ expect(segments).toHaveLength(5)
167
+ expect(segments[0]).toEqual({ text: 'bold', bold: true })
168
+ expect(segments[2]).toEqual({ text: 'code', code: true })
169
+ expect(segments[4]).toEqual({ text: 'italic', italic: true })
170
+ })
171
+
172
+ it('handles text with no formatting', () => {
173
+ const segments = parseInline('just plain text here')
174
+ expect(segments).toHaveLength(1)
175
+ expect(segments[0].text).toBe('just plain text here')
176
+ })
177
+
178
+ // Property: concatenated segment text equals original stripped of formatting
179
+ it('preserves all text content', () => {
180
+ const input = '**bold** and `code` text'
181
+ const segments = parseInline(input)
182
+ const reconstructed = segments.map((s) => s.text).join('')
183
+ expect(reconstructed).toBe('bold and code text')
184
+ })
185
+ })
@@ -0,0 +1,246 @@
1
+ import * as fc from 'fast-check'
2
+ import { describe, it, expect, afterEach } from '@jest/globals'
3
+ import { getTerminalSize, shouldUseBottomPanel } from '../terminal-dimensions'
4
+
5
+ describe('terminal-dimensions service - Property-Based Tests', () => {
6
+ // Store original values
7
+ const originalColumns = process.stdout.columns
8
+ const originalRows = process.stdout.rows
9
+
10
+ afterEach(() => {
11
+ // Restore original values
12
+ process.stdout.columns = originalColumns
13
+ process.stdout.rows = originalRows
14
+ })
15
+
16
+ /**
17
+ * **Validates: Requirements 3.4.3**
18
+ *
19
+ * Property 7: Panel position by terminal size
20
+ *
21
+ * For any terminal dimensions, the detail panel should be positioned on the side
22
+ * when width >= 120, and at the bottom when width < 120. This property validates that:
23
+ * 1. Width >= 120 results in side position (shouldUseBottomPanel returns false)
24
+ * 2. Width < 120 results in bottom position (shouldUseBottomPanel returns true)
25
+ * 3. The threshold boundary at 120 columns is correctly enforced
26
+ */
27
+ it('should position panel based on terminal width threshold of 120 columns', () => {
28
+ fc.assert(
29
+ fc.property(
30
+ // Generate terminal widths from 1 to 300 columns
31
+ fc.integer({ min: 1, max: 300 }),
32
+ // Generate terminal heights from 1 to 100 rows (height doesn't affect panel position)
33
+ fc.integer({ min: 1, max: 100 }),
34
+ (width, height) => {
35
+ // Setup: Set terminal dimensions
36
+ process.stdout.columns = width
37
+ process.stdout.rows = height
38
+
39
+ // Act: Check panel position
40
+ const useBottomPanel = shouldUseBottomPanel()
41
+
42
+ // Assert: Panel should be at bottom when width < 120, side when width >= 120
43
+ const expectedBottomPanel = width < 120
44
+
45
+ expect(useBottomPanel).toBe(expectedBottomPanel)
46
+
47
+ // Additional assertion: Verify terminal size is read correctly
48
+ const size = getTerminalSize()
49
+ expect(size.width).toBe(width)
50
+ expect(size.height).toBe(height)
51
+ }
52
+ ),
53
+ { numRuns: 500 }
54
+ )
55
+ })
56
+
57
+ /**
58
+ * Additional property: Boundary precision at 120 columns
59
+ *
60
+ * Validates that the threshold is precise at exactly 120 columns
61
+ */
62
+ it('should have precise threshold boundary at 120 columns', () => {
63
+ fc.assert(
64
+ fc.property(
65
+ // Generate small offsets around the 120 column boundary (-50 to +50)
66
+ fc.integer({ min: -50, max: 50 }),
67
+ fc.integer({ min: 24, max: 100 }),
68
+ (offset, height) => {
69
+ // Setup: Set width to exactly 120 + offset
70
+ const width = 120 + offset
71
+ process.stdout.columns = width
72
+ process.stdout.rows = height
73
+
74
+ // Act: Check panel position
75
+ const useBottomPanel = shouldUseBottomPanel()
76
+
77
+ // Assert: Should use bottom panel only when offset is negative (width < 120)
78
+ const expectedBottomPanel = offset < 0
79
+
80
+ expect(useBottomPanel).toBe(expectedBottomPanel)
81
+ }
82
+ ),
83
+ { numRuns: 300 }
84
+ )
85
+ })
86
+
87
+ /**
88
+ * Additional property: Panel position independence from height
89
+ *
90
+ * Validates that panel positioning is determined solely by width,
91
+ * not affected by terminal height
92
+ */
93
+ it('should determine panel position based on width only, independent of height', () => {
94
+ fc.assert(
95
+ fc.property(
96
+ fc.integer({ min: 1, max: 300 }),
97
+ // Generate two different heights
98
+ fc.integer({ min: 1, max: 100 }),
99
+ fc.integer({ min: 1, max: 100 }),
100
+ (width, height1, height2) => {
101
+ // Setup: Test with same width but different heights
102
+ process.stdout.columns = width
103
+
104
+ // First test with height1
105
+ process.stdout.rows = height1
106
+ const result1 = shouldUseBottomPanel()
107
+
108
+ // Second test with height2
109
+ process.stdout.rows = height2
110
+ const result2 = shouldUseBottomPanel()
111
+
112
+ // Assert: Both should return the same result since width is the same
113
+ expect(result1).toBe(result2)
114
+
115
+ // Both should match the expected result based on width
116
+ const expectedBottomPanel = width < 120
117
+ expect(result1).toBe(expectedBottomPanel)
118
+ expect(result2).toBe(expectedBottomPanel)
119
+ }
120
+ ),
121
+ { numRuns: 300 }
122
+ )
123
+ })
124
+
125
+ /**
126
+ * Additional property: Consistent results across multiple calls
127
+ *
128
+ * Validates that calling shouldUseBottomPanel multiple times with
129
+ * the same terminal dimensions returns consistent results
130
+ */
131
+ it('should return consistent results across multiple calls with same dimensions', () => {
132
+ fc.assert(
133
+ fc.property(
134
+ fc.integer({ min: 1, max: 300 }),
135
+ fc.integer({ min: 1, max: 100 }),
136
+ // Generate number of calls (2-10)
137
+ fc.integer({ min: 2, max: 10 }),
138
+ (width, height, numCalls) => {
139
+ // Setup: Set terminal dimensions
140
+ process.stdout.columns = width
141
+ process.stdout.rows = height
142
+
143
+ // Act: Call shouldUseBottomPanel multiple times
144
+ const results = Array.from({ length: numCalls }, () => shouldUseBottomPanel())
145
+
146
+ // Assert: All calls should return the same result
147
+ const firstResult = results[0]
148
+ for (const result of results) {
149
+ expect(result).toBe(firstResult)
150
+ }
151
+
152
+ // Verify the result matches the expected value
153
+ const expectedBottomPanel = width < 120
154
+ expect(firstResult).toBe(expectedBottomPanel)
155
+ }
156
+ ),
157
+ { numRuns: 200 }
158
+ )
159
+ })
160
+
161
+ /**
162
+ * Additional property: Edge cases at minimum and maximum widths
163
+ *
164
+ * Validates behavior at extreme terminal widths
165
+ */
166
+ it('should handle extreme terminal widths correctly', () => {
167
+ fc.assert(
168
+ fc.property(
169
+ // Generate extreme widths: very small (1-10) or very large (200-500)
170
+ fc.oneof(
171
+ fc.integer({ min: 1, max: 10 }),
172
+ fc.integer({ min: 200, max: 500 })
173
+ ),
174
+ fc.integer({ min: 1, max: 100 }),
175
+ (width, height) => {
176
+ // Setup: Set terminal dimensions
177
+ process.stdout.columns = width
178
+ process.stdout.rows = height
179
+
180
+ // Act: Check panel position
181
+ const useBottomPanel = shouldUseBottomPanel()
182
+
183
+ // Assert: Should follow the same rule regardless of extreme values
184
+ const expectedBottomPanel = width < 120
185
+
186
+ expect(useBottomPanel).toBe(expectedBottomPanel)
187
+
188
+ // For very small widths, should always use bottom panel
189
+ if (width < 120) {
190
+ expect(useBottomPanel).toBe(true)
191
+ }
192
+
193
+ // For very large widths, should always use side panel
194
+ if (width >= 200) {
195
+ expect(useBottomPanel).toBe(false)
196
+ }
197
+ }
198
+ ),
199
+ { numRuns: 200 }
200
+ )
201
+ })
202
+
203
+ describe('specific boundary examples', () => {
204
+ it('should use bottom panel at exactly 119 columns', () => {
205
+ process.stdout.columns = 119
206
+ process.stdout.rows = 30
207
+
208
+ expect(shouldUseBottomPanel()).toBe(true)
209
+ })
210
+
211
+ it('should use side panel at exactly 120 columns', () => {
212
+ process.stdout.columns = 120
213
+ process.stdout.rows = 30
214
+
215
+ expect(shouldUseBottomPanel()).toBe(false)
216
+ })
217
+
218
+ it('should use side panel at exactly 121 columns', () => {
219
+ process.stdout.columns = 121
220
+ process.stdout.rows = 30
221
+
222
+ expect(shouldUseBottomPanel()).toBe(false)
223
+ })
224
+
225
+ it('should use bottom panel for minimum terminal width', () => {
226
+ process.stdout.columns = 80
227
+ process.stdout.rows = 24
228
+
229
+ expect(shouldUseBottomPanel()).toBe(true)
230
+ })
231
+
232
+ it('should use side panel for very wide terminals', () => {
233
+ process.stdout.columns = 200
234
+ process.stdout.rows = 50
235
+
236
+ expect(shouldUseBottomPanel()).toBe(false)
237
+ })
238
+
239
+ it('should use bottom panel for narrow terminals', () => {
240
+ process.stdout.columns = 100
241
+ process.stdout.rows = 40
242
+
243
+ expect(shouldUseBottomPanel()).toBe(true)
244
+ })
245
+ })
246
+ })
@@ -0,0 +1,109 @@
1
+ import { afterEach, describe, expect, it } from '@jest/globals'
2
+
3
+ import { canShowDetailPanel, getTerminalSize, shouldUseBottomPanel } from '../terminal-dimensions'
4
+
5
+ describe('terminal-dimensions service', () => {
6
+ const originalColumns = process.stdout.columns
7
+ const originalRows = process.stdout.rows
8
+
9
+ const mockTerminalSize = (columns: number | undefined, rows: number | undefined) => {
10
+ Object.defineProperty(process.stdout, 'columns', {
11
+ value: columns,
12
+ configurable: true,
13
+ writable: true,
14
+ })
15
+ Object.defineProperty(process.stdout, 'rows', {
16
+ value: rows,
17
+ configurable: true,
18
+ writable: true,
19
+ })
20
+ }
21
+
22
+ afterEach(() => {
23
+ mockTerminalSize(originalColumns, originalRows)
24
+ })
25
+
26
+ describe('getTerminalSize', () => {
27
+ it('should return current terminal dimensions', () => {
28
+ mockTerminalSize(120, 30)
29
+ const size = getTerminalSize()
30
+ expect(size).toEqual({ width: 120, height: 30 })
31
+ })
32
+
33
+ it('should return default dimensions when stdout values are undefined', () => {
34
+ mockTerminalSize(undefined, undefined)
35
+ const size = getTerminalSize()
36
+ expect(size).toEqual({ width: 80, height: 24 })
37
+ })
38
+
39
+ it('should handle zero values by using defaults', () => {
40
+ mockTerminalSize(0, 0)
41
+ const size = getTerminalSize()
42
+ expect(size).toEqual({ width: 80, height: 24 })
43
+ })
44
+ })
45
+
46
+ describe('shouldUseBottomPanel', () => {
47
+ it('should return true when width is less than 120', () => {
48
+ mockTerminalSize(119, 30)
49
+ const result = shouldUseBottomPanel()
50
+ expect(result).toBe(true)
51
+ })
52
+
53
+ it('should return false when width is 120 or greater', () => {
54
+ mockTerminalSize(120, 30)
55
+ const result = shouldUseBottomPanel()
56
+ expect(result).toBe(false)
57
+ })
58
+
59
+ it('should return false for very wide terminals', () => {
60
+ mockTerminalSize(200, 50)
61
+ const result = shouldUseBottomPanel()
62
+ expect(result).toBe(false)
63
+ })
64
+
65
+ it('should return true for narrow terminals', () => {
66
+ mockTerminalSize(80, 24)
67
+ const result = shouldUseBottomPanel()
68
+ expect(result).toBe(true)
69
+ })
70
+ })
71
+
72
+ describe('canShowDetailPanel', () => {
73
+ it('should return true when dimensions meet minimum requirements', () => {
74
+ mockTerminalSize(80, 24)
75
+ const result = canShowDetailPanel()
76
+ expect(result).toBe(true)
77
+ })
78
+
79
+ it('should return true when dimensions exceed minimum requirements', () => {
80
+ mockTerminalSize(120, 40)
81
+ const result = canShowDetailPanel()
82
+ expect(result).toBe(true)
83
+ })
84
+
85
+ it('should return false when width is below minimum', () => {
86
+ mockTerminalSize(79, 24)
87
+ const result = canShowDetailPanel()
88
+ expect(result).toBe(false)
89
+ })
90
+
91
+ it('should return false when height is below minimum', () => {
92
+ mockTerminalSize(80, 23)
93
+ const result = canShowDetailPanel()
94
+ expect(result).toBe(false)
95
+ })
96
+
97
+ it('should return false when both dimensions are below minimum', () => {
98
+ mockTerminalSize(70, 20)
99
+ const result = canShowDetailPanel()
100
+ expect(result).toBe(false)
101
+ })
102
+
103
+ it('should handle default dimensions correctly', () => {
104
+ mockTerminalSize(undefined, undefined)
105
+ const result = canShowDetailPanel()
106
+ expect(result).toBe(true)
107
+ })
108
+ })
109
+ })