@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.
- package/CHANGELOG.md +0 -0
- package/jest.config.ts +26 -0
- package/package.json +1 -1
- package/project.json +74 -0
- package/src/app.tsx +56 -0
- package/src/atoms/deprecatedSkills.ts +11 -0
- package/src/atoms/environmentCheck.ts +51 -0
- package/src/atoms/installedSkills.ts +36 -0
- package/src/atoms/wizard.ts +6 -0
- package/src/cli/audit.ts +21 -0
- package/src/cli/cache.ts +28 -0
- package/src/cli/install.ts +93 -0
- package/src/cli/list.ts +41 -0
- package/src/cli/remove.ts +70 -0
- package/src/cli/update.ts +107 -0
- package/src/components/AnimatedTransition.tsx +42 -0
- package/src/components/AuditLogViewer.tsx +85 -0
- package/src/components/CategoryHeader.tsx +39 -0
- package/src/components/ConfirmPrompt.tsx +34 -0
- package/src/components/FooterBar.tsx +36 -0
- package/src/components/Header.tsx +97 -0
- package/src/components/InstallResults.tsx +110 -0
- package/src/components/KeyboardShortcutsOverlay.tsx +112 -0
- package/src/components/MultiSelectPrompt.tsx +219 -0
- package/src/components/SearchInput.tsx +36 -0
- package/src/components/SelectPrompt.tsx +108 -0
- package/src/components/SkillCard.tsx +74 -0
- package/src/components/SkillDetailPanel.tsx +233 -0
- package/src/components/StatusBadge.tsx +45 -0
- package/src/components/__tests__/AnimatedTransition.pbt.test.tsx +51 -0
- package/src/components/__tests__/CategoryHeader.pbt.test.tsx +107 -0
- package/src/components/__tests__/CategoryHeader.test.tsx +105 -0
- package/src/components/__tests__/KeyboardShortcutsOverlay.pbt.test.tsx +155 -0
- package/src/components/__tests__/KeyboardShortcutsOverlay.test.tsx +136 -0
- package/src/components/__tests__/SkillDetailPanel.test.tsx +273 -0
- package/src/components/index.ts +12 -0
- package/src/hooks/__tests__/useConfig.test.ts +242 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useAgents.ts +28 -0
- package/src/hooks/useConfig.ts +114 -0
- package/src/hooks/useFilter.ts +31 -0
- package/src/hooks/useInstaller.ts +39 -0
- package/src/hooks/useKeyboardNav.ts +39 -0
- package/src/hooks/useKonamiCode.ts +48 -0
- package/src/hooks/useRemover.ts +59 -0
- package/src/hooks/useSkillContent.ts +67 -0
- package/src/hooks/useSkills.ts +38 -0
- package/src/hooks/useWizardStep.ts +19 -0
- package/src/index.ts +129 -0
- package/src/services/__tests__/audit-log.spec.ts +220 -0
- package/src/services/__tests__/badge-format.test.ts +102 -0
- package/src/services/__tests__/category-colors.test.ts +253 -0
- package/src/services/__tests__/config.test.ts +184 -0
- package/src/services/__tests__/installer.security.spec.ts +151 -0
- package/src/services/__tests__/lockfile.security.spec.ts +132 -0
- package/src/services/__tests__/markdown-parser.spec.ts +185 -0
- package/src/services/__tests__/terminal-dimensions.pbt.test.ts +246 -0
- package/src/services/__tests__/terminal-dimensions.test.ts +109 -0
- package/src/services/__tests__/update-cache.pbt.test.ts +214 -0
- package/src/services/__tests__/update-cache.test.ts +215 -0
- package/src/services/agents.ts +42 -0
- package/src/services/audio-player.ts +55 -0
- package/src/services/audit-log.ts +69 -0
- package/src/services/badge-format.ts +4 -0
- package/src/services/categories.ts +176 -0
- package/src/services/category-colors.ts +19 -0
- package/src/services/config.ts +84 -0
- package/src/services/github-contributors.ts +56 -0
- package/src/services/global-path.ts +20 -0
- package/src/services/index.ts +21 -0
- package/src/services/installer.ts +371 -0
- package/src/services/lockfile.ts +177 -0
- package/src/services/markdown-parser.ts +108 -0
- package/src/services/package-info.ts +19 -0
- package/src/services/project-root.ts +18 -0
- package/src/services/registry.ts +382 -0
- package/src/services/skills-provider.ts +169 -0
- package/src/services/terminal-dimensions.ts +18 -0
- package/src/services/update-cache.ts +65 -0
- package/src/services/update-check.ts +26 -0
- package/src/theme/colors.ts +24 -0
- package/src/theme/index.ts +2 -0
- package/src/theme/symbols.ts +22 -0
- package/src/types.ts +38 -0
- package/src/utils/constants.ts +49 -0
- package/src/utils/paths.ts +52 -0
- package/src/views/ActionSelector.tsx +45 -0
- package/src/views/AgentSelector.tsx +105 -0
- package/src/views/CreditsView.tsx +332 -0
- package/src/views/InstallConfig.tsx +162 -0
- package/src/views/InstallWizard.tsx +181 -0
- package/src/views/ListView.tsx +41 -0
- package/src/views/RemoveWizard.tsx +237 -0
- package/src/views/SkillBrowser.tsx +504 -0
- package/src/views/UpdateView.tsx +272 -0
- package/src/views/arcade/ArcadeMenu.tsx +89 -0
- package/src/views/arcade/VibeInvaders.tsx +339 -0
- package/src/views/arcade/index.ts +2 -0
- package/src/views/index.ts +11 -0
- package/tsconfig.json +19 -0
- package/tsconfig.spec.json +25 -0
- package/LICENSE +0 -26
- package/README.md +0 -257
- package/index.js +0 -12
- package/index.js.map +0 -7
- /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
|
+
})
|