@aaronshaf/ger 0.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/.ast-grep/rules/no-as-casting.yml +13 -0
- package/.eslintrc.js +12 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +78 -0
- package/.github/workflows/claude.yml +64 -0
- package/.github/workflows/dependency-update.yml +84 -0
- package/.github/workflows/release.yml +166 -0
- package/.github/workflows/security-scan.yml +113 -0
- package/.github/workflows/security.yml +96 -0
- package/.husky/pre-commit +16 -0
- package/.husky/pre-push +25 -0
- package/.lintstagedrc.json +6 -0
- package/.tool-versions +1 -0
- package/CLAUDE.md +103 -0
- package/DEVELOPMENT.md +361 -0
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/bin/ger +3 -0
- package/biome.json +36 -0
- package/bun.lock +688 -0
- package/bunfig.toml +8 -0
- package/oxlint.json +24 -0
- package/package.json +55 -0
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/src/api/gerrit.ts +466 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/comment.ts +460 -0
- package/src/cli/commands/comments.ts +85 -0
- package/src/cli/commands/diff.ts +71 -0
- package/src/cli/commands/incoming.ts +226 -0
- package/src/cli/commands/init.ts +164 -0
- package/src/cli/commands/mine.ts +115 -0
- package/src/cli/commands/open.ts +57 -0
- package/src/cli/commands/review.ts +593 -0
- package/src/cli/commands/setup.ts +230 -0
- package/src/cli/commands/show.ts +303 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +420 -0
- package/src/prompts/default-review.md +80 -0
- package/src/prompts/system-inline-review.md +88 -0
- package/src/prompts/system-overall-review.md +152 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +75 -0
- package/src/schemas/gerrit.ts +455 -0
- package/src/services/ai-enhanced.ts +167 -0
- package/src/services/ai.ts +182 -0
- package/src/services/config.test.ts +414 -0
- package/src/services/config.ts +206 -0
- package/src/test-utils/mock-generator.ts +73 -0
- package/src/utils/comment-formatters.ts +153 -0
- package/src/utils/diff-context.ts +103 -0
- package/src/utils/diff-formatters.ts +141 -0
- package/src/utils/formatters.ts +85 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +123 -0
- package/src/utils/url-parser.ts +91 -0
- package/tests/abandon.test.ts +163 -0
- package/tests/ai-service.test.ts +489 -0
- package/tests/comment-batch-advanced.test.ts +431 -0
- package/tests/comment-gerrit-api-compliance.test.ts +414 -0
- package/tests/comment.test.ts +707 -0
- package/tests/comments.test.ts +323 -0
- package/tests/config-service-simple.test.ts +100 -0
- package/tests/diff.test.ts +419 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +318 -0
- package/tests/mocks/fetch-mock.ts +139 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/review.test.ts +669 -0
- package/tests/setup.ts +13 -0
- package/tests/show.test.ts +439 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/test-utils/mock-generator.test.ts +154 -0
- package/tests/unit/utils/comment-formatters.test.ts +415 -0
- package/tests/unit/utils/diff-context.test.ts +171 -0
- package/tests/unit/utils/diff-formatters.test.ts +165 -0
- package/tests/unit/utils/formatters.test.ts +411 -0
- package/tests/unit/utils/message-filters.test.ts +227 -0
- package/tests/unit/utils/prompt-helpers.test.ts +175 -0
- package/tests/unit/utils/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- package/tsconfig.json +40 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { filterMeaningfulMessages, sortMessagesByDate } from '@/utils/message-filters'
|
|
3
|
+
import type { MessageInfo } from '@/schemas/gerrit'
|
|
4
|
+
|
|
5
|
+
describe('Message Filters', () => {
|
|
6
|
+
describe('filterMeaningfulMessages', () => {
|
|
7
|
+
test('should filter out empty messages', () => {
|
|
8
|
+
const messages: MessageInfo[] = [
|
|
9
|
+
{
|
|
10
|
+
id: 'msg1',
|
|
11
|
+
message: 'Code-Review+2',
|
|
12
|
+
author: { _account_id: 1001, name: 'Jane Reviewer' },
|
|
13
|
+
date: '2024-01-15 11:30:00.000000000',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: 'msg2',
|
|
17
|
+
message: '',
|
|
18
|
+
author: { _account_id: 1002, name: 'Bob Reviewer' },
|
|
19
|
+
date: '2024-01-15 11:31:00.000000000',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'msg3',
|
|
23
|
+
message: ' ',
|
|
24
|
+
author: { _account_id: 1003, name: 'Alice Reviewer' },
|
|
25
|
+
date: '2024-01-15 11:32:00.000000000',
|
|
26
|
+
},
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const filtered = filterMeaningfulMessages(messages)
|
|
30
|
+
|
|
31
|
+
expect(filtered).toHaveLength(1)
|
|
32
|
+
expect(filtered[0].id).toBe('msg1')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('should filter out autogenerated newPatchSet messages', () => {
|
|
36
|
+
const messages: MessageInfo[] = [
|
|
37
|
+
{
|
|
38
|
+
id: 'msg1',
|
|
39
|
+
message: 'Uploaded patch set 1.',
|
|
40
|
+
author: { _account_id: 1001, name: 'Author' },
|
|
41
|
+
date: '2024-01-15 11:30:00.000000000',
|
|
42
|
+
tag: 'autogenerated:gerrit:newPatchSet',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'msg2',
|
|
46
|
+
message: 'Code-Review+2',
|
|
47
|
+
author: { _account_id: 1002, name: 'Reviewer' },
|
|
48
|
+
date: '2024-01-15 11:31:00.000000000',
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
const filtered = filterMeaningfulMessages(messages)
|
|
53
|
+
|
|
54
|
+
expect(filtered).toHaveLength(1)
|
|
55
|
+
expect(filtered[0].id).toBe('msg2')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('should filter out autogenerated merged messages', () => {
|
|
59
|
+
const messages: MessageInfo[] = [
|
|
60
|
+
{
|
|
61
|
+
id: 'msg1',
|
|
62
|
+
message: 'Change has been successfully merged',
|
|
63
|
+
author: { _account_id: 1001, name: 'Author' },
|
|
64
|
+
date: '2024-01-15 11:30:00.000000000',
|
|
65
|
+
tag: 'autogenerated:gerrit:merged',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'msg2',
|
|
69
|
+
message: 'Code-Review+2',
|
|
70
|
+
author: { _account_id: 1002, name: 'Reviewer' },
|
|
71
|
+
date: '2024-01-15 11:31:00.000000000',
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
const filtered = filterMeaningfulMessages(messages)
|
|
76
|
+
|
|
77
|
+
expect(filtered).toHaveLength(1)
|
|
78
|
+
expect(filtered[0].id).toBe('msg2')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('should keep build and review status messages', () => {
|
|
82
|
+
const messages: MessageInfo[] = [
|
|
83
|
+
{
|
|
84
|
+
id: 'msg1',
|
|
85
|
+
message: 'Patch Set 1: Verified+1\\n\\nBuild Successful',
|
|
86
|
+
author: { _account_id: 1001, name: 'Jenkins' },
|
|
87
|
+
date: '2024-01-15 11:30:00.000000000',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'msg2',
|
|
91
|
+
message: 'Patch Set 1: Code-Review+2',
|
|
92
|
+
author: { _account_id: 1002, name: 'Reviewer' },
|
|
93
|
+
date: '2024-01-15 11:31:00.000000000',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'msg3',
|
|
97
|
+
message: 'Patch Set 1: Lint-Review-1\\n\\nThis commit may not be safe to merge',
|
|
98
|
+
author: { _account_id: 1003, name: 'Lint Bot' },
|
|
99
|
+
date: '2024-01-15 11:32:00.000000000',
|
|
100
|
+
},
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
const filtered = filterMeaningfulMessages(messages)
|
|
104
|
+
|
|
105
|
+
expect(filtered).toHaveLength(3)
|
|
106
|
+
expect(filtered.map((m) => m.id)).toEqual(['msg1', 'msg2', 'msg3'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('should handle messages without author', () => {
|
|
110
|
+
const messages: MessageInfo[] = [
|
|
111
|
+
{
|
|
112
|
+
id: 'msg1',
|
|
113
|
+
message: 'System message',
|
|
114
|
+
date: '2024-01-15 11:30:00.000000000',
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
const filtered = filterMeaningfulMessages(messages)
|
|
119
|
+
|
|
120
|
+
expect(filtered).toHaveLength(1)
|
|
121
|
+
expect(filtered[0].id).toBe('msg1')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('should handle empty input array', () => {
|
|
125
|
+
const filtered = filterMeaningfulMessages([])
|
|
126
|
+
|
|
127
|
+
expect(filtered).toHaveLength(0)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('sortMessagesByDate', () => {
|
|
132
|
+
test('should sort messages by date with newest first', () => {
|
|
133
|
+
const messages: MessageInfo[] = [
|
|
134
|
+
{
|
|
135
|
+
id: 'msg1',
|
|
136
|
+
message: 'First message',
|
|
137
|
+
date: '2024-01-15 11:30:00.000000000',
|
|
138
|
+
author: { _account_id: 1001 },
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'msg2',
|
|
142
|
+
message: 'Second message',
|
|
143
|
+
date: '2024-01-15 11:31:00.000000000',
|
|
144
|
+
author: { _account_id: 1002 },
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'msg3',
|
|
148
|
+
message: 'Third message',
|
|
149
|
+
date: '2024-01-15 11:29:00.000000000',
|
|
150
|
+
author: { _account_id: 1003 },
|
|
151
|
+
},
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
const sorted = sortMessagesByDate(messages)
|
|
155
|
+
|
|
156
|
+
expect(sorted.map((m) => m.id)).toEqual(['msg2', 'msg1', 'msg3'])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('should handle messages with same timestamp', () => {
|
|
160
|
+
const messages: MessageInfo[] = [
|
|
161
|
+
{
|
|
162
|
+
id: 'msg1',
|
|
163
|
+
message: 'First message',
|
|
164
|
+
date: '2024-01-15 11:30:00.000000000',
|
|
165
|
+
author: { _account_id: 1001 },
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: 'msg2',
|
|
169
|
+
message: 'Second message',
|
|
170
|
+
date: '2024-01-15 11:30:00.000000000',
|
|
171
|
+
author: { _account_id: 1002 },
|
|
172
|
+
},
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
const sorted = sortMessagesByDate(messages)
|
|
176
|
+
|
|
177
|
+
expect(sorted).toHaveLength(2)
|
|
178
|
+
// Order should be maintained for same timestamps
|
|
179
|
+
expect(sorted.map((m) => m.id)).toEqual(['msg1', 'msg2'])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('should not mutate original array', () => {
|
|
183
|
+
const messages: MessageInfo[] = [
|
|
184
|
+
{
|
|
185
|
+
id: 'msg1',
|
|
186
|
+
message: 'First message',
|
|
187
|
+
date: '2024-01-15 11:31:00.000000000',
|
|
188
|
+
author: { _account_id: 1001 },
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'msg2',
|
|
192
|
+
message: 'Second message',
|
|
193
|
+
date: '2024-01-15 11:30:00.000000000',
|
|
194
|
+
author: { _account_id: 1002 },
|
|
195
|
+
},
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
const originalOrder = messages.map((m) => m.id)
|
|
199
|
+
const sorted = sortMessagesByDate(messages)
|
|
200
|
+
|
|
201
|
+
expect(messages.map((m) => m.id)).toEqual(originalOrder)
|
|
202
|
+
expect(sorted.map((m) => m.id)).toEqual(['msg1', 'msg2'])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('should handle empty input array', () => {
|
|
206
|
+
const sorted = sortMessagesByDate([])
|
|
207
|
+
|
|
208
|
+
expect(sorted).toHaveLength(0)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('should handle readonly arrays', () => {
|
|
212
|
+
const messages: readonly MessageInfo[] = [
|
|
213
|
+
{
|
|
214
|
+
id: 'msg1',
|
|
215
|
+
message: 'Message',
|
|
216
|
+
date: '2024-01-15 11:30:00.000000000',
|
|
217
|
+
author: { _account_id: 1001 },
|
|
218
|
+
},
|
|
219
|
+
] as const
|
|
220
|
+
|
|
221
|
+
const sorted = sortMessagesByDate(messages)
|
|
222
|
+
|
|
223
|
+
expect(sorted).toHaveLength(1)
|
|
224
|
+
expect(sorted[0].id).toBe('msg1')
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import * as os from 'node:os'
|
|
4
|
+
import * as path from 'node:path'
|
|
5
|
+
|
|
6
|
+
// Since the helper functions are private, we'll redefine them here for testing
|
|
7
|
+
// This ensures they behave exactly like the ones in the review command
|
|
8
|
+
|
|
9
|
+
// Helper to expand tilde in file paths
|
|
10
|
+
const expandTilde = (filePath: string): string => {
|
|
11
|
+
if (filePath.startsWith('~/')) {
|
|
12
|
+
return path.join(os.homedir(), filePath.slice(2))
|
|
13
|
+
}
|
|
14
|
+
return filePath
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Helper to read prompt file
|
|
18
|
+
const readPromptFile = (filePath: string): string | null => {
|
|
19
|
+
try {
|
|
20
|
+
const expanded = expandTilde(filePath)
|
|
21
|
+
if (fs.existsSync(expanded)) {
|
|
22
|
+
return fs.readFileSync(expanded, 'utf8')
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// Ignore errors
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('Prompt Helper Functions', () => {
|
|
31
|
+
describe('expandTilde', () => {
|
|
32
|
+
test('should expand tilde (~/) to home directory', () => {
|
|
33
|
+
const homeDir = os.homedir()
|
|
34
|
+
const result = expandTilde('~/test/file.txt')
|
|
35
|
+
expect(result).toBe(path.join(homeDir, 'test/file.txt'))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('should handle tilde with no trailing slash', () => {
|
|
39
|
+
const homeDir = os.homedir()
|
|
40
|
+
const result = expandTilde('~/file.txt')
|
|
41
|
+
expect(result).toBe(path.join(homeDir, 'file.txt'))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('should return unchanged path when not starting with tilde', () => {
|
|
45
|
+
const absolutePath = '/absolute/path/file.txt'
|
|
46
|
+
const result = expandTilde(absolutePath)
|
|
47
|
+
expect(result).toBe(absolutePath)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('should return unchanged path for relative paths without tilde', () => {
|
|
51
|
+
const relativePath = 'relative/path/file.txt'
|
|
52
|
+
const result = expandTilde(relativePath)
|
|
53
|
+
expect(result).toBe(relativePath)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('should handle empty string', () => {
|
|
57
|
+
const result = expandTilde('')
|
|
58
|
+
expect(result).toBe('')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('should handle just tilde', () => {
|
|
62
|
+
const result = expandTilde('~')
|
|
63
|
+
expect(result).toBe('~')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('should handle tilde in middle of path', () => {
|
|
67
|
+
const pathWithTildeInMiddle = '/path/~/file.txt'
|
|
68
|
+
const result = expandTilde(pathWithTildeInMiddle)
|
|
69
|
+
expect(result).toBe(pathWithTildeInMiddle)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('readPromptFile', () => {
|
|
74
|
+
test('should read existing file content', () => {
|
|
75
|
+
const tempDir = os.tmpdir()
|
|
76
|
+
const tempFile = path.join(tempDir, `test-read-${Date.now()}.md`)
|
|
77
|
+
const testContent = 'Test prompt content\nWith multiple lines'
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
fs.writeFileSync(tempFile, testContent, 'utf8')
|
|
81
|
+
const result = readPromptFile(tempFile)
|
|
82
|
+
expect(result).toBe(testContent)
|
|
83
|
+
} finally {
|
|
84
|
+
if (fs.existsSync(tempFile)) {
|
|
85
|
+
fs.unlinkSync(tempFile)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('should return null for non-existent file', () => {
|
|
91
|
+
const nonExistentFile = '/tmp/does-not-exist-prompt.md'
|
|
92
|
+
const result = readPromptFile(nonExistentFile)
|
|
93
|
+
expect(result).toBeNull()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('should expand tilde paths before reading', () => {
|
|
97
|
+
const homeDir = os.homedir()
|
|
98
|
+
const fileName = `.test-tilde-read-${Date.now()}.md`
|
|
99
|
+
const absolutePath = path.join(homeDir, fileName)
|
|
100
|
+
const tildePath = `~/${fileName}`
|
|
101
|
+
const testContent = 'Tilde path test content'
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
fs.writeFileSync(absolutePath, testContent, 'utf8')
|
|
105
|
+
const result = readPromptFile(tildePath)
|
|
106
|
+
expect(result).toBe(testContent)
|
|
107
|
+
} finally {
|
|
108
|
+
if (fs.existsSync(absolutePath)) {
|
|
109
|
+
fs.unlinkSync(absolutePath)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('should handle permission errors gracefully', () => {
|
|
115
|
+
// Try to read from a restricted directory
|
|
116
|
+
const restrictedFile = '/root/test-permission-error.md'
|
|
117
|
+
const result = readPromptFile(restrictedFile)
|
|
118
|
+
expect(result).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('should handle directory instead of file', () => {
|
|
122
|
+
const tempDir = os.tmpdir()
|
|
123
|
+
const result = readPromptFile(tempDir)
|
|
124
|
+
expect(result).toBeNull()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('should handle empty file', () => {
|
|
128
|
+
const tempDir = os.tmpdir()
|
|
129
|
+
const tempFile = path.join(tempDir, `test-empty-${Date.now()}.md`)
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
fs.writeFileSync(tempFile, '', 'utf8')
|
|
133
|
+
const result = readPromptFile(tempFile)
|
|
134
|
+
expect(result).toBe('')
|
|
135
|
+
} finally {
|
|
136
|
+
if (fs.existsSync(tempFile)) {
|
|
137
|
+
fs.unlinkSync(tempFile)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('should handle file with special characters', () => {
|
|
143
|
+
const tempDir = os.tmpdir()
|
|
144
|
+
const tempFile = path.join(tempDir, `test-special-${Date.now()}.md`)
|
|
145
|
+
const specialContent =
|
|
146
|
+
'Content with special chars: ñáéíóú 中文 🚀 "quotes" \\backslashes\\ /slashes/'
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
fs.writeFileSync(tempFile, specialContent, 'utf8')
|
|
150
|
+
const result = readPromptFile(tempFile)
|
|
151
|
+
expect(result).toBe(specialContent)
|
|
152
|
+
} finally {
|
|
153
|
+
if (fs.existsSync(tempFile)) {
|
|
154
|
+
fs.unlinkSync(tempFile)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('should handle very large file', () => {
|
|
160
|
+
const tempDir = os.tmpdir()
|
|
161
|
+
const tempFile = path.join(tempDir, `test-large-${Date.now()}.md`)
|
|
162
|
+
const largeContent = 'Large content '.repeat(10000) // ~140KB
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
fs.writeFileSync(tempFile, largeContent, 'utf8')
|
|
166
|
+
const result = readPromptFile(tempFile)
|
|
167
|
+
expect(result).toBe(largeContent)
|
|
168
|
+
} finally {
|
|
169
|
+
if (fs.existsSync(tempFile)) {
|
|
170
|
+
fs.unlinkSync(tempFile)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { test, expect, describe } from 'bun:test'
|
|
2
|
+
import { Effect } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
sanitizeUrl,
|
|
5
|
+
sanitizeUrlSync,
|
|
6
|
+
getOpenCommand,
|
|
7
|
+
sanitizeCDATA,
|
|
8
|
+
escapeXML,
|
|
9
|
+
} from '@/utils/shell-safety'
|
|
10
|
+
|
|
11
|
+
describe('Shell Safety Utilities', () => {
|
|
12
|
+
describe('sanitizeUrl (Effect-based)', () => {
|
|
13
|
+
test('should accept valid HTTPS URLs', async () => {
|
|
14
|
+
const url = 'https://gerrit.example.com/c/project/+/12345'
|
|
15
|
+
const result = await Effect.runPromise(sanitizeUrl(url).pipe(Effect.either))
|
|
16
|
+
|
|
17
|
+
expect(result._tag).toBe('Right')
|
|
18
|
+
if (result._tag === 'Right') {
|
|
19
|
+
expect(result.right).toBe(url)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('should reject HTTP URLs', async () => {
|
|
24
|
+
const url = 'http://gerrit.example.com/c/project/+/12345'
|
|
25
|
+
const result = await Effect.runPromise(sanitizeUrl(url).pipe(Effect.either))
|
|
26
|
+
|
|
27
|
+
expect(result._tag).toBe('Left')
|
|
28
|
+
if (result._tag === 'Left') {
|
|
29
|
+
expect(result.left.message).toContain('Invalid protocol')
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('should reject URLs with dangerous characters', async () => {
|
|
34
|
+
const dangerousUrls = [
|
|
35
|
+
'https://gerrit.example.com/c/project/+/12345;rm -rf /',
|
|
36
|
+
'https://gerrit.example.com/c/project/+/12345`whoami`',
|
|
37
|
+
'https://gerrit.example.com/c/project/+/12345$(whoami)',
|
|
38
|
+
'https://gerrit.example.com/c/project/+/12345|ls',
|
|
39
|
+
'https://gerrit.example.com/c/project/+/12345&sleep 10',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
for (const url of dangerousUrls) {
|
|
43
|
+
const result = await Effect.runPromise(sanitizeUrl(url).pipe(Effect.either))
|
|
44
|
+
expect(result._tag).toBe('Left')
|
|
45
|
+
if (result._tag === 'Left') {
|
|
46
|
+
expect(result.left.message).toContain('dangerous characters')
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('should reject malformed URLs', async () => {
|
|
52
|
+
const invalidUrls = ['not-a-url', 'https://', 'https:///', '', 'ftp://example.com']
|
|
53
|
+
|
|
54
|
+
for (const url of invalidUrls) {
|
|
55
|
+
const result = await Effect.runPromise(sanitizeUrl(url).pipe(Effect.either))
|
|
56
|
+
expect(result._tag).toBe('Left')
|
|
57
|
+
if (result._tag === 'Left') {
|
|
58
|
+
expect(result.left.message).toContain('Invalid')
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('should accept complex but safe URLs', async () => {
|
|
64
|
+
const safeUrls = [
|
|
65
|
+
'https://gerrit.example.com/c/project/+/12345',
|
|
66
|
+
'https://gerrit.example.com/c/my-project/+/12345/1',
|
|
67
|
+
'https://gerrit.example.com:8080/c/project/+/12345',
|
|
68
|
+
'https://gerrit-review.example.com/c/project-name/+/12345',
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
for (const url of safeUrls) {
|
|
72
|
+
const result = await Effect.runPromise(sanitizeUrl(url).pipe(Effect.either))
|
|
73
|
+
expect(result._tag).toBe('Right')
|
|
74
|
+
if (result._tag === 'Right') {
|
|
75
|
+
expect(result.right).toBe(url)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('sanitizeUrlSync (synchronous)', () => {
|
|
82
|
+
test('should accept valid HTTPS URLs', () => {
|
|
83
|
+
const url = 'https://gerrit.example.com/c/project/+/12345'
|
|
84
|
+
expect(() => sanitizeUrlSync(url)).not.toThrow()
|
|
85
|
+
expect(sanitizeUrlSync(url)).toBe(url)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('should reject HTTP URLs', () => {
|
|
89
|
+
const url = 'http://gerrit.example.com/c/project/+/12345'
|
|
90
|
+
expect(() => sanitizeUrlSync(url)).toThrow('Invalid protocol')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('should reject URLs with dangerous characters', () => {
|
|
94
|
+
const url = 'https://gerrit.example.com/c/project/+/12345;rm -rf /'
|
|
95
|
+
expect(() => sanitizeUrlSync(url)).toThrow('dangerous characters')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('should reject malformed URLs', () => {
|
|
99
|
+
const url = 'not-a-url'
|
|
100
|
+
expect(() => sanitizeUrlSync(url)).toThrow('Invalid URL format')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('getOpenCommand', () => {
|
|
105
|
+
test('should return correct command for each platform', () => {
|
|
106
|
+
const originalPlatform = process.platform
|
|
107
|
+
|
|
108
|
+
// Test macOS
|
|
109
|
+
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
|
110
|
+
expect(getOpenCommand()).toBe('open')
|
|
111
|
+
|
|
112
|
+
// Test Windows
|
|
113
|
+
Object.defineProperty(process, 'platform', { value: 'win32' })
|
|
114
|
+
expect(getOpenCommand()).toBe('start')
|
|
115
|
+
|
|
116
|
+
// Test Linux
|
|
117
|
+
Object.defineProperty(process, 'platform', { value: 'linux' })
|
|
118
|
+
expect(getOpenCommand()).toBe('xdg-open')
|
|
119
|
+
|
|
120
|
+
// Test other Unix-like systems
|
|
121
|
+
Object.defineProperty(process, 'platform', { value: 'freebsd' })
|
|
122
|
+
expect(getOpenCommand()).toBe('xdg-open')
|
|
123
|
+
|
|
124
|
+
// Restore original platform
|
|
125
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('URL edge cases', () => {
|
|
130
|
+
test('should handle URLs with ports', () => {
|
|
131
|
+
const url = 'https://gerrit.example.com:8080/c/project/+/12345'
|
|
132
|
+
expect(() => sanitizeUrlSync(url)).not.toThrow()
|
|
133
|
+
expect(sanitizeUrlSync(url)).toBe(url)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('should handle URLs with query parameters', () => {
|
|
137
|
+
const url = 'https://gerrit.example.com/c/project/+/12345?tab=comments'
|
|
138
|
+
expect(() => sanitizeUrlSync(url)).not.toThrow()
|
|
139
|
+
expect(sanitizeUrlSync(url)).toBe(url)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('should handle URLs with fragments', () => {
|
|
143
|
+
const url = 'https://gerrit.example.com/c/project/+/12345#message-abc123'
|
|
144
|
+
expect(() => sanitizeUrlSync(url)).not.toThrow()
|
|
145
|
+
expect(sanitizeUrlSync(url)).toBe(url)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('should reject URLs with empty hostnames', () => {
|
|
149
|
+
// Note: new URL('https:///path') actually creates a valid URL object with hostname 'c'
|
|
150
|
+
// So let's test with a truly malformed URL
|
|
151
|
+
expect(() => sanitizeUrlSync('https:///')).toThrow('Invalid URL format')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('sanitizeCDATA', () => {
|
|
156
|
+
test('should handle normal text content', () => {
|
|
157
|
+
const input = 'This is normal text content\nwith multiple lines'
|
|
158
|
+
expect(sanitizeCDATA(input)).toBe(input)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('should escape CDATA end sequences', () => {
|
|
162
|
+
const input = 'Some content with ]]> dangerous sequence'
|
|
163
|
+
const expected = 'Some content with ]]> dangerous sequence'
|
|
164
|
+
expect(sanitizeCDATA(input)).toBe(expected)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('should remove null bytes', () => {
|
|
168
|
+
const input = 'Content with\x00null bytes'
|
|
169
|
+
const expected = 'Content withnull bytes'
|
|
170
|
+
expect(sanitizeCDATA(input)).toBe(expected)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('should remove control characters but keep allowed ones', () => {
|
|
174
|
+
const input = 'Content\twith\ntab\rand\x08backspace\x1fcontrol'
|
|
175
|
+
const expected = 'Content\twith\ntab\randbackspacecontrol'
|
|
176
|
+
expect(sanitizeCDATA(input)).toBe(expected)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('should handle empty string', () => {
|
|
180
|
+
expect(sanitizeCDATA('')).toBe('')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('should throw error for non-string input', () => {
|
|
184
|
+
expect(() => sanitizeCDATA(123 as never)).toThrow('Content must be a string')
|
|
185
|
+
expect(() => sanitizeCDATA(null as never)).toThrow('Content must be a string')
|
|
186
|
+
expect(() => sanitizeCDATA(undefined as never)).toThrow('Content must be a string')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('should handle complex CDATA injection attempts', () => {
|
|
190
|
+
const input = 'Normal content]]><script>alert("xss")</script><![CDATA[more content'
|
|
191
|
+
const expected = 'Normal content]]><script>alert("xss")</script><![CDATA[more content'
|
|
192
|
+
expect(sanitizeCDATA(input)).toBe(expected)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('escapeXML', () => {
|
|
197
|
+
test('should escape all XML special characters', () => {
|
|
198
|
+
const input = 'Text with & < > " \' characters'
|
|
199
|
+
const expected = 'Text with & < > " ' characters'
|
|
200
|
+
expect(escapeXML(input)).toBe(expected)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('should handle normal text without special characters', () => {
|
|
204
|
+
const input = 'Normal text content'
|
|
205
|
+
expect(escapeXML(input)).toBe(input)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('should handle empty string', () => {
|
|
209
|
+
expect(escapeXML('')).toBe('')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('should throw error for non-string input', () => {
|
|
213
|
+
expect(() => escapeXML(123 as never)).toThrow('Content must be a string')
|
|
214
|
+
expect(() => escapeXML(null as never)).toThrow('Content must be a string')
|
|
215
|
+
expect(() => escapeXML(undefined as never)).toThrow('Content must be a string')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('should handle complex XML injection attempts', () => {
|
|
219
|
+
const input = '<script src="evil.js"></script>&malicious;'
|
|
220
|
+
const expected = '<script src="evil.js"></script>&malicious;'
|
|
221
|
+
expect(escapeXML(input)).toBe(expected)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('should handle ampersand properly', () => {
|
|
225
|
+
const input = 'AT&T & Johnson & Johnson'
|
|
226
|
+
const expected = 'AT&T & Johnson & Johnson'
|
|
227
|
+
expect(escapeXML(input)).toBe(expected)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
})
|