@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,357 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { Effect, Layer } from 'effect'
|
|
3
|
+
import { HttpResponse, http } from 'msw'
|
|
4
|
+
import { setupServer } from 'msw/node'
|
|
5
|
+
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
6
|
+
import { incomingCommand } from '@/cli/commands/incoming'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
|
|
9
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
10
|
+
// Create MSW server
|
|
11
|
+
const server = setupServer(
|
|
12
|
+
// Default handler for auth check
|
|
13
|
+
http.get('*/a/accounts/self', ({ request }) => {
|
|
14
|
+
const auth = request.headers.get('Authorization')
|
|
15
|
+
if (!auth || !auth.startsWith('Basic ')) {
|
|
16
|
+
return HttpResponse.text('Unauthorized', { status: 401 })
|
|
17
|
+
}
|
|
18
|
+
return HttpResponse.json({
|
|
19
|
+
_account_id: 1000,
|
|
20
|
+
name: 'Test User',
|
|
21
|
+
email: 'test@example.com',
|
|
22
|
+
})
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
describe('incoming command', () => {
|
|
27
|
+
let mockConsoleLog: ReturnType<typeof mock>
|
|
28
|
+
let mockConsoleError: ReturnType<typeof mock>
|
|
29
|
+
|
|
30
|
+
beforeAll(() => {
|
|
31
|
+
// Start MSW server before all tests
|
|
32
|
+
server.listen({ onUnhandledRequest: 'bypass' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
// Clean up after all tests
|
|
37
|
+
server.close()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
// Reset handlers to defaults before each test
|
|
42
|
+
server.resetHandlers()
|
|
43
|
+
|
|
44
|
+
mockConsoleLog = mock(() => {})
|
|
45
|
+
mockConsoleError = mock(() => {})
|
|
46
|
+
console.log = mockConsoleLog
|
|
47
|
+
console.error = mockConsoleError
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
// Clean up after each test
|
|
52
|
+
server.resetHandlers()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should fetch and display incoming changes in pretty format', async () => {
|
|
56
|
+
server.use(
|
|
57
|
+
http.get('*/a/changes/', ({ request }) => {
|
|
58
|
+
const url = new URL(request.url)
|
|
59
|
+
const query = url.searchParams.get('q')
|
|
60
|
+
|
|
61
|
+
// Verify the correct query
|
|
62
|
+
if (query === 'is:open -owner:self -is:wip -is:ignored reviewer:self') {
|
|
63
|
+
return HttpResponse.text(`)]}'\n[
|
|
64
|
+
{
|
|
65
|
+
"id": "team-project~main~I123abc",
|
|
66
|
+
"_number": 1001,
|
|
67
|
+
"project": "team-project",
|
|
68
|
+
"branch": "main",
|
|
69
|
+
"subject": "Fix critical bug in authentication",
|
|
70
|
+
"status": "NEW",
|
|
71
|
+
"change_id": "I123abc",
|
|
72
|
+
"owner": {
|
|
73
|
+
"_account_id": 2001,
|
|
74
|
+
"name": "Alice Developer",
|
|
75
|
+
"email": "alice@example.com"
|
|
76
|
+
},
|
|
77
|
+
"updated": "2024-01-15 10:30:00.000000000"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "team-project~feature%2Fnew-api~I456def",
|
|
81
|
+
"_number": 1002,
|
|
82
|
+
"project": "team-project",
|
|
83
|
+
"branch": "feature/new-api",
|
|
84
|
+
"subject": "Add new API endpoint",
|
|
85
|
+
"status": "NEW",
|
|
86
|
+
"change_id": "I456def",
|
|
87
|
+
"owner": {
|
|
88
|
+
"_account_id": 2002,
|
|
89
|
+
"name": "Bob Builder",
|
|
90
|
+
"email": "bob@example.com"
|
|
91
|
+
},
|
|
92
|
+
"updated": "2024-01-15 11:00:00.000000000"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"id": "another-project~main~I789ghi",
|
|
96
|
+
"_number": 1003,
|
|
97
|
+
"project": "another-project",
|
|
98
|
+
"branch": "main",
|
|
99
|
+
"subject": "Update documentation",
|
|
100
|
+
"status": "NEW",
|
|
101
|
+
"change_id": "I789ghi",
|
|
102
|
+
"owner": {
|
|
103
|
+
"_account_id": 2003,
|
|
104
|
+
"name": "Charlie Coder",
|
|
105
|
+
"email": "charlie@example.com"
|
|
106
|
+
},
|
|
107
|
+
"updated": "2024-01-15 09:00:00.000000000"
|
|
108
|
+
}
|
|
109
|
+
]`)
|
|
110
|
+
}
|
|
111
|
+
return HttpResponse.json([])
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
116
|
+
|
|
117
|
+
const program = incomingCommand({}).pipe(
|
|
118
|
+
Effect.provide(GerritApiServiceLive),
|
|
119
|
+
Effect.provide(mockConfigLayer),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
await Effect.runPromise(program)
|
|
123
|
+
|
|
124
|
+
// Check that changes were displayed
|
|
125
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
126
|
+
|
|
127
|
+
// Should not have header since we removed it
|
|
128
|
+
expect(output).not.toContain('Incoming changes for review')
|
|
129
|
+
|
|
130
|
+
// Check project grouping
|
|
131
|
+
expect(output).toContain('team-project')
|
|
132
|
+
expect(output).toContain('another-project')
|
|
133
|
+
|
|
134
|
+
// Check change details
|
|
135
|
+
expect(output).toContain('1001')
|
|
136
|
+
expect(output).toContain('Fix critical bug in authentication')
|
|
137
|
+
expect(output).toContain('by Alice Developer')
|
|
138
|
+
expect(output).toContain('1002')
|
|
139
|
+
expect(output).toContain('Add new API endpoint')
|
|
140
|
+
expect(output).toContain('by Bob Builder')
|
|
141
|
+
expect(output).toContain('1003')
|
|
142
|
+
expect(output).toContain('Update documentation')
|
|
143
|
+
expect(output).toContain('by Charlie Coder')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should output XML format when --xml flag is used', async () => {
|
|
147
|
+
server.use(
|
|
148
|
+
http.get('*/a/changes/', ({ request }) => {
|
|
149
|
+
const url = new URL(request.url)
|
|
150
|
+
const query = url.searchParams.get('q')
|
|
151
|
+
|
|
152
|
+
if (query === 'is:open -owner:self -is:wip -is:ignored reviewer:self') {
|
|
153
|
+
return HttpResponse.text(`)]}'\n[
|
|
154
|
+
{
|
|
155
|
+
"id": "xml-project~develop~Ixmltest",
|
|
156
|
+
"_number": 2001,
|
|
157
|
+
"project": "xml-project",
|
|
158
|
+
"branch": "develop",
|
|
159
|
+
"subject": "XML test change",
|
|
160
|
+
"status": "NEW",
|
|
161
|
+
"change_id": "Ixmltest",
|
|
162
|
+
"owner": {
|
|
163
|
+
"_account_id": 3001,
|
|
164
|
+
"name": "XML User",
|
|
165
|
+
"email": "xml@example.com"
|
|
166
|
+
},
|
|
167
|
+
"updated": "2024-01-15 14:00:00.000000000"
|
|
168
|
+
}
|
|
169
|
+
]`)
|
|
170
|
+
}
|
|
171
|
+
return HttpResponse.json([])
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
176
|
+
|
|
177
|
+
const program = incomingCommand({ xml: true }).pipe(
|
|
178
|
+
Effect.provide(GerritApiServiceLive),
|
|
179
|
+
Effect.provide(mockConfigLayer),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
await Effect.runPromise(program)
|
|
183
|
+
|
|
184
|
+
// Check XML output structure
|
|
185
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
186
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
187
|
+
expect(output).toContain('<incoming_reviews>')
|
|
188
|
+
expect(output).toContain('<count>1</count>')
|
|
189
|
+
expect(output).toContain('<changes>')
|
|
190
|
+
expect(output).toContain('<change>')
|
|
191
|
+
expect(output).toContain('<number>2001</number>')
|
|
192
|
+
expect(output).toContain('<subject><![CDATA[XML test change]]></subject>')
|
|
193
|
+
// Project is now an attribute of project element
|
|
194
|
+
expect(output).toContain('<project name="xml-project">')
|
|
195
|
+
expect(output).toContain('<status>NEW</status>')
|
|
196
|
+
expect(output).toContain('<owner>XML User</owner>')
|
|
197
|
+
expect(output).toContain('</change>')
|
|
198
|
+
expect(output).toContain('</changes>')
|
|
199
|
+
expect(output).toContain('</incoming_reviews>')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('should handle no incoming changes gracefully', async () => {
|
|
203
|
+
server.use(
|
|
204
|
+
http.get('*/a/changes/', () => {
|
|
205
|
+
return HttpResponse.text(`)]}'\n[]`)
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
210
|
+
|
|
211
|
+
const program = incomingCommand({}).pipe(
|
|
212
|
+
Effect.provide(GerritApiServiceLive),
|
|
213
|
+
Effect.provide(mockConfigLayer),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
await Effect.runPromise(program)
|
|
217
|
+
|
|
218
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
219
|
+
expect(output).toContain('✓ No incoming reviews')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('should handle network failures gracefully', async () => {
|
|
223
|
+
server.use(
|
|
224
|
+
http.get('*/a/changes/', () => {
|
|
225
|
+
return HttpResponse.error()
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
230
|
+
|
|
231
|
+
const program = incomingCommand({}).pipe(
|
|
232
|
+
Effect.provide(GerritApiServiceLive),
|
|
233
|
+
Effect.provide(mockConfigLayer),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('should handle authentication failures', async () => {
|
|
240
|
+
server.use(
|
|
241
|
+
http.get('*/a/changes/', () => {
|
|
242
|
+
return HttpResponse.text('Unauthorized', { status: 401 })
|
|
243
|
+
}),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
247
|
+
|
|
248
|
+
const program = incomingCommand({}).pipe(
|
|
249
|
+
Effect.provide(GerritApiServiceLive),
|
|
250
|
+
Effect.provide(mockConfigLayer),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
await expect(Effect.runPromise(program)).rejects.toThrow()
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('should properly escape XML special characters', async () => {
|
|
257
|
+
server.use(
|
|
258
|
+
http.get('*/a/changes/', () => {
|
|
259
|
+
return HttpResponse.text(`)]}'\n[
|
|
260
|
+
{
|
|
261
|
+
"id": "test-project~main~Itest",
|
|
262
|
+
"_number": 3001,
|
|
263
|
+
"project": "test<>&\\"project",
|
|
264
|
+
"branch": "main",
|
|
265
|
+
"subject": "Fix <script>alert('XSS')</script> & entities",
|
|
266
|
+
"status": "NEW",
|
|
267
|
+
"change_id": "I<>&test",
|
|
268
|
+
"owner": {
|
|
269
|
+
"_account_id": 4001,
|
|
270
|
+
"name": "User <>&\\"'",
|
|
271
|
+
"email": "test@example.com"
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
]`)
|
|
275
|
+
}),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
279
|
+
|
|
280
|
+
const program = incomingCommand({ xml: true }).pipe(
|
|
281
|
+
Effect.provide(GerritApiServiceLive),
|
|
282
|
+
Effect.provide(mockConfigLayer),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
await Effect.runPromise(program)
|
|
286
|
+
|
|
287
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
288
|
+
// Subject should be in CDATA
|
|
289
|
+
expect(output).toContain(
|
|
290
|
+
"<subject><![CDATA[Fix <script>alert('XSS')</script> & entities]]></subject>",
|
|
291
|
+
)
|
|
292
|
+
// Owner name should be preserved in output
|
|
293
|
+
expect(output).toContain('<owner>User <>&"\'</owner>')
|
|
294
|
+
// Project and change_id should be in output
|
|
295
|
+
// Project name should be in the project element attribute
|
|
296
|
+
expect(output).toContain('<project name="test<>&')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('should group changes by project alphabetically', async () => {
|
|
300
|
+
server.use(
|
|
301
|
+
http.get('*/a/changes/', () => {
|
|
302
|
+
return HttpResponse.text(`)]}'\n[
|
|
303
|
+
{
|
|
304
|
+
"id": "zebra-project~main~Izebra",
|
|
305
|
+
"_number": 4001,
|
|
306
|
+
"project": "zebra-project",
|
|
307
|
+
"branch": "main",
|
|
308
|
+
"subject": "Change in zebra",
|
|
309
|
+
"status": "NEW",
|
|
310
|
+
"change_id": "Izebra",
|
|
311
|
+
"owner": {"_account_id": 5001, "name": "Zoe"}
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
"id": "alpha-project~main~Ialpha",
|
|
315
|
+
"_number": 4002,
|
|
316
|
+
"project": "alpha-project",
|
|
317
|
+
"branch": "main",
|
|
318
|
+
"subject": "Change in alpha",
|
|
319
|
+
"status": "NEW",
|
|
320
|
+
"change_id": "Ialpha",
|
|
321
|
+
"owner": {"_account_id": 5002, "name": "Amy"}
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
"id": "beta-project~main~Ibeta",
|
|
325
|
+
"_number": 4003,
|
|
326
|
+
"project": "beta-project",
|
|
327
|
+
"branch": "main",
|
|
328
|
+
"subject": "Change in beta",
|
|
329
|
+
"status": "NEW",
|
|
330
|
+
"change_id": "Ibeta",
|
|
331
|
+
"owner": {"_account_id": 5003, "name": "Ben"}
|
|
332
|
+
}
|
|
333
|
+
]`)
|
|
334
|
+
}),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
338
|
+
|
|
339
|
+
const program = incomingCommand({}).pipe(
|
|
340
|
+
Effect.provide(GerritApiServiceLive),
|
|
341
|
+
Effect.provide(mockConfigLayer),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
await Effect.runPromise(program)
|
|
345
|
+
|
|
346
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
347
|
+
|
|
348
|
+
// Find positions of project names in output
|
|
349
|
+
const alphaPos = output.indexOf('alpha-project')
|
|
350
|
+
const betaPos = output.indexOf('beta-project')
|
|
351
|
+
const zebraPos = output.indexOf('zebra-project')
|
|
352
|
+
|
|
353
|
+
// Verify alphabetical order
|
|
354
|
+
expect(alphaPos).toBeLessThan(betaPos)
|
|
355
|
+
expect(betaPos).toBeLessThan(zebraPos)
|
|
356
|
+
})
|
|
357
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { incomingCommand } from '@/cli/commands/incoming'
|
|
3
|
+
import { generateMockChange } from '@/test-utils/mock-generator'
|
|
4
|
+
import {
|
|
5
|
+
getStatusIndicators,
|
|
6
|
+
getStatusString,
|
|
7
|
+
getLabelValue,
|
|
8
|
+
getLabelColor,
|
|
9
|
+
} from '@/utils/status-indicators'
|
|
10
|
+
import { sanitizeUrlSync, getOpenCommand } from '@/utils/shell-safety'
|
|
11
|
+
|
|
12
|
+
describe('Interactive Incoming Command', () => {
|
|
13
|
+
test('should create command with interactive option', () => {
|
|
14
|
+
const command = incomingCommand({ interactive: true })
|
|
15
|
+
expect(command).toBeDefined()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('should create command with interactive and xml options', () => {
|
|
19
|
+
const command = incomingCommand({ interactive: true, xml: true })
|
|
20
|
+
expect(command).toBeDefined()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('should create command without interactive option', () => {
|
|
24
|
+
const command = incomingCommand({})
|
|
25
|
+
expect(command).toBeDefined()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('Status Indicators Utility', () => {
|
|
29
|
+
test('should generate status indicators for approved change', () => {
|
|
30
|
+
const change = generateMockChange({
|
|
31
|
+
labels: {
|
|
32
|
+
'Code-Review': { approved: { _account_id: 1 }, value: 2 },
|
|
33
|
+
Verified: { approved: { _account_id: 1 }, value: 1 },
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const indicators = getStatusIndicators(change)
|
|
38
|
+
expect(indicators).toContain('✓')
|
|
39
|
+
expect(indicators.length).toBeGreaterThan(0)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('should generate status indicators for rejected change', () => {
|
|
43
|
+
const change = generateMockChange({
|
|
44
|
+
labels: {
|
|
45
|
+
'Code-Review': { rejected: { _account_id: 1 }, value: -2 },
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const indicators = getStatusIndicators(change)
|
|
50
|
+
expect(indicators).toContain('✗')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('should generate padded status string', () => {
|
|
54
|
+
const change = generateMockChange({
|
|
55
|
+
labels: {
|
|
56
|
+
'Code-Review': { recommended: { _account_id: 1 }, value: 1 },
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const statusString = getStatusString(change, undefined, 8)
|
|
61
|
+
expect(statusString).toContain('↑')
|
|
62
|
+
expect(statusString.length).toBe(8)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('should handle empty labels gracefully', () => {
|
|
66
|
+
const change = generateMockChange({ labels: {} })
|
|
67
|
+
const indicators = getStatusIndicators(change)
|
|
68
|
+
expect(indicators).toHaveLength(0)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('should extract label value safely', () => {
|
|
72
|
+
expect(getLabelValue({ value: 2 })).toBe(2)
|
|
73
|
+
expect(getLabelValue({ value: -1 })).toBe(-1)
|
|
74
|
+
expect(getLabelValue({})).toBe(0)
|
|
75
|
+
expect(getLabelValue(null)).toBe(0)
|
|
76
|
+
expect(getLabelValue('invalid')).toBe(0)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('should determine label color correctly', () => {
|
|
80
|
+
expect(getLabelColor(2)).toBe('green')
|
|
81
|
+
expect(getLabelColor(1)).toBe('green')
|
|
82
|
+
expect(getLabelColor(0)).toBe('yellow')
|
|
83
|
+
expect(getLabelColor(-1)).toBe('red')
|
|
84
|
+
expect(getLabelColor(-2)).toBe('red')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('URL Sanitization', () => {
|
|
89
|
+
test('should sanitize valid HTTPS URLs', () => {
|
|
90
|
+
const url = 'https://gerrit.example.com/c/project/+/12345'
|
|
91
|
+
expect(() => sanitizeUrlSync(url)).not.toThrow()
|
|
92
|
+
expect(sanitizeUrlSync(url)).toBe(url)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('should reject HTTP URLs', () => {
|
|
96
|
+
const url = 'http://gerrit.example.com/c/project/+/12345'
|
|
97
|
+
expect(() => sanitizeUrlSync(url)).toThrow('Invalid protocol')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('should reject URLs with dangerous characters', () => {
|
|
101
|
+
const url = 'https://gerrit.example.com/c/project/+/12345;rm -rf /'
|
|
102
|
+
expect(() => sanitizeUrlSync(url)).toThrow('dangerous characters')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('should reject malformed URLs', () => {
|
|
106
|
+
const url = 'not-a-url'
|
|
107
|
+
expect(() => sanitizeUrlSync(url)).toThrow('Invalid URL format')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('should get correct open command for platform', () => {
|
|
111
|
+
const originalPlatform = process.platform
|
|
112
|
+
|
|
113
|
+
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
|
114
|
+
expect(getOpenCommand()).toBe('open')
|
|
115
|
+
|
|
116
|
+
Object.defineProperty(process, 'platform', { value: 'win32' })
|
|
117
|
+
expect(getOpenCommand()).toBe('start')
|
|
118
|
+
|
|
119
|
+
Object.defineProperty(process, 'platform', { value: 'linux' })
|
|
120
|
+
expect(getOpenCommand()).toBe('xdg-open')
|
|
121
|
+
|
|
122
|
+
// Restore original platform
|
|
123
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('Change Data Processing', () => {
|
|
128
|
+
test('should handle changes with various label configurations', () => {
|
|
129
|
+
const changes = [
|
|
130
|
+
generateMockChange({
|
|
131
|
+
_number: 1,
|
|
132
|
+
labels: { 'Code-Review': { value: 2, approved: { _account_id: 1 } } },
|
|
133
|
+
}),
|
|
134
|
+
generateMockChange({
|
|
135
|
+
_number: 2,
|
|
136
|
+
labels: { 'Code-Review': { value: -1, disliked: { _account_id: 1 } } },
|
|
137
|
+
}),
|
|
138
|
+
generateMockChange({
|
|
139
|
+
_number: 3,
|
|
140
|
+
labels: {},
|
|
141
|
+
}),
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
changes.forEach((change) => {
|
|
145
|
+
expect(() => getStatusIndicators(change)).not.toThrow()
|
|
146
|
+
expect(() => getStatusString(change)).not.toThrow()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('should group changes by project correctly', () => {
|
|
151
|
+
const changes = [
|
|
152
|
+
generateMockChange({ project: 'project-a', _number: 1 }),
|
|
153
|
+
generateMockChange({ project: 'project-b', _number: 2 }),
|
|
154
|
+
generateMockChange({ project: 'project-a', _number: 3 }),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
const grouped = changes.reduce(
|
|
158
|
+
(acc, change) => {
|
|
159
|
+
if (!acc[change.project]) {
|
|
160
|
+
acc[change.project] = []
|
|
161
|
+
}
|
|
162
|
+
acc[change.project].push(change)
|
|
163
|
+
return acc
|
|
164
|
+
},
|
|
165
|
+
{} as Record<string, typeof changes>,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
expect(Object.keys(grouped)).toHaveLength(2)
|
|
169
|
+
expect(grouped['project-a']).toHaveLength(2)
|
|
170
|
+
expect(grouped['project-b']).toHaveLength(1)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
})
|