@aaronshaf/ger 0.3.2 → 0.3.3

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.
@@ -0,0 +1,393 @@
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 { addReviewerCommand } from '@/cli/commands/add-reviewer'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './helpers/config-mock'
9
+
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('add-reviewer command', () => {
27
+ let mockConsoleLog: ReturnType<typeof mock>
28
+ let mockConsoleError: ReturnType<typeof mock>
29
+
30
+ beforeAll(() => {
31
+ server.listen({ onUnhandledRequest: 'bypass' })
32
+ })
33
+
34
+ afterAll(() => {
35
+ server.close()
36
+ })
37
+
38
+ beforeEach(() => {
39
+ mockConsoleLog = mock(() => {})
40
+ mockConsoleError = mock(() => {})
41
+ console.log = mockConsoleLog
42
+ console.error = mockConsoleError
43
+ })
44
+
45
+ afterEach(() => {
46
+ server.resetHandlers()
47
+ })
48
+
49
+ it('should add a single reviewer successfully', async () => {
50
+ server.use(
51
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
52
+ const body = (await request.json()) as { reviewer: string; state?: string }
53
+ expect(body.reviewer).toBe('reviewer@example.com')
54
+ expect(body.state).toBe('REVIEWER')
55
+ return HttpResponse.text(
56
+ `)]}'\n${JSON.stringify({
57
+ input: 'reviewer@example.com',
58
+ reviewers: [
59
+ {
60
+ _account_id: 2000,
61
+ name: 'Reviewer User',
62
+ email: 'reviewer@example.com',
63
+ },
64
+ ],
65
+ })}`,
66
+ )
67
+ }),
68
+ )
69
+
70
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
71
+ const program = addReviewerCommand(['reviewer@example.com'], {
72
+ change: '12345',
73
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
74
+
75
+ await Effect.runPromise(program)
76
+
77
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
78
+ expect(output).toContain('Added Reviewer User as reviewer')
79
+ })
80
+
81
+ it('should add multiple reviewers successfully', async () => {
82
+ let callCount = 0
83
+ server.use(
84
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
85
+ const body = (await request.json()) as { reviewer: string }
86
+ callCount++
87
+ const reviewerName = callCount === 1 ? 'User One' : 'User Two'
88
+ return HttpResponse.text(
89
+ `)]}'\n${JSON.stringify({
90
+ input: body.reviewer,
91
+ reviewers: [
92
+ {
93
+ _account_id: 2000 + callCount,
94
+ name: reviewerName,
95
+ email: body.reviewer,
96
+ },
97
+ ],
98
+ })}`,
99
+ )
100
+ }),
101
+ )
102
+
103
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
104
+ const program = addReviewerCommand(['user1@example.com', 'user2@example.com'], {
105
+ change: '12345',
106
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
107
+
108
+ await Effect.runPromise(program)
109
+
110
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
111
+ expect(output).toContain('Added User One as reviewer')
112
+ expect(output).toContain('Added User Two as reviewer')
113
+ expect(callCount).toBe(2)
114
+ })
115
+
116
+ it('should add as CC when --cc flag is used', async () => {
117
+ server.use(
118
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
119
+ const body = (await request.json()) as { reviewer: string; state?: string }
120
+ expect(body.reviewer).toBe('cc@example.com')
121
+ expect(body.state).toBe('CC')
122
+ return HttpResponse.text(
123
+ `)]}'\n${JSON.stringify({
124
+ input: 'cc@example.com',
125
+ ccs: [
126
+ {
127
+ _account_id: 2000,
128
+ name: 'CC User',
129
+ email: 'cc@example.com',
130
+ },
131
+ ],
132
+ })}`,
133
+ )
134
+ }),
135
+ )
136
+
137
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
138
+ const program = addReviewerCommand(['cc@example.com'], {
139
+ change: '12345',
140
+ cc: true,
141
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
142
+
143
+ await Effect.runPromise(program)
144
+
145
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
146
+ expect(output).toContain('Added CC User as cc')
147
+ })
148
+
149
+ it('should pass notify option to API', async () => {
150
+ server.use(
151
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
152
+ const body = (await request.json()) as { reviewer: string; notify?: string }
153
+ expect(body.notify).toBe('NONE')
154
+ return HttpResponse.text(
155
+ `)]}'\n${JSON.stringify({
156
+ input: 'reviewer@example.com',
157
+ reviewers: [
158
+ {
159
+ _account_id: 2000,
160
+ name: 'Reviewer',
161
+ email: 'reviewer@example.com',
162
+ },
163
+ ],
164
+ })}`,
165
+ )
166
+ }),
167
+ )
168
+
169
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
170
+ const program = addReviewerCommand(['reviewer@example.com'], {
171
+ change: '12345',
172
+ notify: 'none',
173
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
174
+
175
+ await Effect.runPromise(program)
176
+
177
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
178
+ expect(output).toContain('Added Reviewer as reviewer')
179
+ })
180
+
181
+ it('should handle API error in result', async () => {
182
+ server.use(
183
+ http.post('*/a/changes/12345/reviewers', async () => {
184
+ return HttpResponse.text(
185
+ `)]}'\n${JSON.stringify({
186
+ input: 'nonexistent@example.com',
187
+ error: 'Account not found: nonexistent@example.com',
188
+ })}`,
189
+ )
190
+ }),
191
+ )
192
+
193
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
194
+ const program = addReviewerCommand(['nonexistent@example.com'], {
195
+ change: '12345',
196
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
197
+
198
+ await Effect.runPromise(program)
199
+
200
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
201
+ expect(errorOutput).toContain('Failed to add nonexistent@example.com')
202
+ expect(errorOutput).toContain('Account not found')
203
+ })
204
+
205
+ it('should show error when change ID is not provided', async () => {
206
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
207
+ const program = addReviewerCommand(['reviewer@example.com'], {}).pipe(
208
+ Effect.provide(GerritApiServiceLive),
209
+ Effect.provide(mockConfigLayer),
210
+ )
211
+
212
+ const result = await Effect.runPromiseExit(program)
213
+ expect(result._tag).toBe('Failure')
214
+
215
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
216
+ expect(errorOutput).toContain('Change ID is required')
217
+ })
218
+
219
+ it('should show error when no reviewers are provided', async () => {
220
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
221
+ const program = addReviewerCommand([], {
222
+ change: '12345',
223
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
224
+
225
+ const result = await Effect.runPromiseExit(program)
226
+ expect(result._tag).toBe('Failure')
227
+
228
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
229
+ expect(errorOutput).toContain('At least one reviewer is required')
230
+ })
231
+
232
+ it('should output XML format when --xml flag is used', async () => {
233
+ server.use(
234
+ http.post('*/a/changes/12345/reviewers', async () => {
235
+ return HttpResponse.text(
236
+ `)]}'\n${JSON.stringify({
237
+ input: 'reviewer@example.com',
238
+ reviewers: [
239
+ {
240
+ _account_id: 2000,
241
+ name: 'Reviewer User',
242
+ email: 'reviewer@example.com',
243
+ },
244
+ ],
245
+ })}`,
246
+ )
247
+ }),
248
+ )
249
+
250
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
251
+ const program = addReviewerCommand(['reviewer@example.com'], {
252
+ change: '12345',
253
+ xml: true,
254
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
255
+
256
+ await Effect.runPromise(program)
257
+
258
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
259
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
260
+ expect(output).toContain('<add_reviewer_result>')
261
+ expect(output).toContain('<change_id>12345</change_id>')
262
+ expect(output).toContain('<state>reviewer</state>')
263
+ expect(output).toContain('<reviewer status="added">')
264
+ expect(output).toContain('<input>reviewer@example.com</input>')
265
+ expect(output).toContain('<name><![CDATA[Reviewer User]]></name>')
266
+ expect(output).toContain('<status>success</status>')
267
+ expect(output).toContain('</add_reviewer_result>')
268
+ })
269
+
270
+ it('should output XML format for errors when --xml flag is used', async () => {
271
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
272
+ const program = addReviewerCommand(['reviewer@example.com'], {
273
+ xml: true,
274
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
275
+
276
+ const result = await Effect.runPromiseExit(program)
277
+ expect(result._tag).toBe('Failure')
278
+
279
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
280
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
281
+ expect(output).toContain('<add_reviewer_result>')
282
+ expect(output).toContain('<status>error</status>')
283
+ expect(output).toContain('<error><![CDATA[Change ID is required')
284
+ expect(output).toContain('</add_reviewer_result>')
285
+ })
286
+
287
+ it('should handle network errors gracefully', async () => {
288
+ server.use(
289
+ http.post('*/a/changes/12345/reviewers', () => {
290
+ return HttpResponse.text('Internal Server Error', { status: 500 })
291
+ }),
292
+ )
293
+
294
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
295
+ const program = addReviewerCommand(['reviewer@example.com'], {
296
+ change: '12345',
297
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
298
+
299
+ await Effect.runPromise(program)
300
+
301
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
302
+ expect(errorOutput).toContain('Failed to add reviewer@example.com')
303
+ })
304
+
305
+ it('should handle partial success with multiple reviewers', async () => {
306
+ let callCount = 0
307
+ server.use(
308
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
309
+ const body = (await request.json()) as { reviewer: string }
310
+ callCount++
311
+ if (callCount === 1) {
312
+ return HttpResponse.text(
313
+ `)]}'\n${JSON.stringify({
314
+ input: body.reviewer,
315
+ reviewers: [
316
+ {
317
+ _account_id: 2001,
318
+ name: 'Valid User',
319
+ email: body.reviewer,
320
+ },
321
+ ],
322
+ })}`,
323
+ )
324
+ }
325
+ return HttpResponse.text(
326
+ `)]}'\n${JSON.stringify({
327
+ input: body.reviewer,
328
+ error: 'Account not found',
329
+ })}`,
330
+ )
331
+ }),
332
+ )
333
+
334
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
335
+ const program = addReviewerCommand(['valid@example.com', 'invalid@example.com'], {
336
+ change: '12345',
337
+ xml: true,
338
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
339
+
340
+ await Effect.runPromise(program)
341
+
342
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
343
+ expect(output).toContain('<status>partial_failure</status>')
344
+ expect(output).toContain('<reviewer status="added">')
345
+ expect(output).toContain('<reviewer status="failed">')
346
+ })
347
+
348
+ it('should reject invalid notify option', async () => {
349
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
350
+ const program = addReviewerCommand(['reviewer@example.com'], {
351
+ change: '12345',
352
+ notify: 'invalid',
353
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
354
+
355
+ const result = await Effect.runPromiseExit(program)
356
+ expect(result._tag).toBe('Failure')
357
+
358
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
359
+ expect(errorOutput).toContain('Invalid notify level: invalid')
360
+ expect(errorOutput).toContain('Valid values: none, owner, owner_reviewers, all')
361
+ })
362
+
363
+ it('should pass REVIEWER state by default (not CC)', async () => {
364
+ let receivedState: string | undefined
365
+ server.use(
366
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
367
+ const body = (await request.json()) as { reviewer: string; state?: string }
368
+ receivedState = body.state
369
+ return HttpResponse.text(
370
+ `)]}'\n${JSON.stringify({
371
+ input: body.reviewer,
372
+ reviewers: [
373
+ {
374
+ _account_id: 2000,
375
+ name: 'Reviewer',
376
+ email: body.reviewer,
377
+ },
378
+ ],
379
+ })}`,
380
+ )
381
+ }),
382
+ )
383
+
384
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
385
+ const program = addReviewerCommand(['reviewer@example.com'], {
386
+ change: '12345',
387
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
388
+
389
+ await Effect.runPromise(program)
390
+
391
+ expect(receivedState).toBe('REVIEWER')
392
+ })
393
+ })
@@ -0,0 +1,246 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { Effect, Layer } from 'effect'
3
+ import { HttpResponse, http } from 'msw'
4
+ import { setupServer } from 'msw/node'
5
+ import { ConfigService } from '@/services/config'
6
+ import { CommitHookService, CommitHookServiceLive } from '@/services/commit-hook'
7
+ import { createMockConfigService } from '../helpers/config-mock'
8
+
9
+ // Sample valid commit-msg hook script
10
+ const VALID_HOOK_SCRIPT = `#!/bin/sh
11
+ # From Gerrit Code Review 3.x
12
+ #
13
+ # Part of Gerrit Code Review (https://www.gerritcodereview.com/)
14
+
15
+ # Add a Change-Id line to commit messages that don't have one
16
+ add_change_id() {
17
+ # ... hook implementation
18
+ echo "Change-Id: I$(git hash-object -t blob /dev/null)"
19
+ }
20
+
21
+ add_change_id
22
+ `
23
+
24
+ // Create MSW server for hook download tests
25
+ const server = setupServer()
26
+
27
+ describe('CommitHookService Integration Tests', () => {
28
+ beforeAll(() => {
29
+ server.listen({ onUnhandledRequest: 'bypass' })
30
+ })
31
+
32
+ afterAll(() => {
33
+ server.close()
34
+ })
35
+
36
+ afterEach(() => {
37
+ server.resetHandlers()
38
+ })
39
+
40
+ describe('installHook', () => {
41
+ test('should successfully download hook from Gerrit server', async () => {
42
+ // Setup handler for successful hook download
43
+ server.use(
44
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
45
+ return HttpResponse.text(VALID_HOOK_SCRIPT, { status: 200 })
46
+ }),
47
+ )
48
+
49
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
50
+
51
+ // Note: We can't fully test installHook without git repo context,
52
+ // but we can verify the HTTP request is made correctly
53
+ const effect = Effect.gen(function* () {
54
+ const service = yield* CommitHookService
55
+ yield* service.installHook()
56
+ }).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
57
+
58
+ // Run with Effect.exit to capture the result without throwing
59
+ const exit = await Effect.runPromiseExit(effect)
60
+
61
+ // The test verifies the service can be constructed and HTTP fetch succeeds
62
+ // It will fail with NotGitRepoError because we're not in a git repo,
63
+ // but it should NOT fail due to HTTP issues
64
+ if (exit._tag === 'Failure') {
65
+ const errorStr = String(exit.cause)
66
+ // Should fail due to git repo issues, not HTTP issues
67
+ expect(errorStr).not.toContain('Failed to download')
68
+ expect(errorStr).not.toContain('fetch')
69
+ }
70
+ })
71
+
72
+ test('should handle 404 error when hook URL is not found', async () => {
73
+ server.use(
74
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
75
+ return HttpResponse.text('Not Found', { status: 404 })
76
+ }),
77
+ )
78
+
79
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
80
+
81
+ const effect = Effect.gen(function* () {
82
+ const service = yield* CommitHookService
83
+ yield* service.installHook()
84
+ }).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
85
+
86
+ const result = await Effect.runPromise(effect).catch((e) => e)
87
+
88
+ // Should fail with HookInstallError due to 404
89
+ expect(result).toBeInstanceOf(Error)
90
+ expect(String(result)).toContain('Failed to download')
91
+ })
92
+
93
+ test('should handle 500 server error gracefully', async () => {
94
+ server.use(
95
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
96
+ return HttpResponse.text('Internal Server Error', { status: 500 })
97
+ }),
98
+ )
99
+
100
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
101
+
102
+ const effect = Effect.gen(function* () {
103
+ const service = yield* CommitHookService
104
+ yield* service.installHook()
105
+ }).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
106
+
107
+ const result = await Effect.runPromise(effect).catch((e) => e)
108
+
109
+ expect(result).toBeInstanceOf(Error)
110
+ expect(String(result)).toContain('Failed to download')
111
+ })
112
+
113
+ test('should reject invalid hook content (not a script)', async () => {
114
+ server.use(
115
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
116
+ // Return HTML instead of shell script
117
+ return HttpResponse.text('<html><body>Error page</body></html>', { status: 200 })
118
+ }),
119
+ )
120
+
121
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
122
+
123
+ const effect = Effect.gen(function* () {
124
+ const service = yield* CommitHookService
125
+ yield* service.installHook()
126
+ }).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
127
+
128
+ const result = await Effect.runPromise(effect).catch((e) => e)
129
+
130
+ expect(result).toBeInstanceOf(Error)
131
+ expect(String(result)).toContain('valid script')
132
+ })
133
+
134
+ test('should handle network timeout', async () => {
135
+ server.use(
136
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', async () => {
137
+ // Simulate network delay that would cause timeout
138
+ await new Promise((resolve) => setTimeout(resolve, 100))
139
+ return HttpResponse.error()
140
+ }),
141
+ )
142
+
143
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
144
+
145
+ const effect = Effect.gen(function* () {
146
+ const service = yield* CommitHookService
147
+ yield* service.installHook()
148
+ }).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
149
+
150
+ const result = await Effect.runPromise(effect).catch((e) => e)
151
+
152
+ expect(result).toBeInstanceOf(Error)
153
+ })
154
+
155
+ test('should handle host with trailing slash', async () => {
156
+ // Use a host with trailing slash
157
+ const configWithTrailingSlash = createMockConfigService({
158
+ host: 'https://test.gerrit.com/',
159
+ username: 'testuser',
160
+ password: 'testpass',
161
+ })
162
+
163
+ server.use(
164
+ // The trailing slash should be normalized, so this handler should match
165
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
166
+ return HttpResponse.text(VALID_HOOK_SCRIPT, { status: 200 })
167
+ }),
168
+ )
169
+
170
+ const mockConfigLayer = Layer.succeed(ConfigService, configWithTrailingSlash)
171
+
172
+ const effect = Effect.gen(function* () {
173
+ const service = yield* CommitHookService
174
+ yield* service.installHook()
175
+ }).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
176
+
177
+ // Should not fail due to double slash in URL
178
+ await Effect.runPromise(effect).catch((e) => {
179
+ // May fail for git repo reasons, but should not fail for URL issues
180
+ expect(String(e)).not.toContain('//tools')
181
+ })
182
+ })
183
+ })
184
+
185
+ describe('hook script validation', () => {
186
+ test('should accept sh shebang', async () => {
187
+ server.use(
188
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
189
+ return HttpResponse.text('#!/bin/sh\necho "hook"', { status: 200 })
190
+ }),
191
+ )
192
+
193
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
194
+
195
+ const effect = Effect.gen(function* () {
196
+ const service = yield* CommitHookService
197
+ yield* service.installHook()
198
+ }).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
199
+
200
+ const result = await Effect.runPromise(effect).catch((e) => e)
201
+
202
+ // Should not fail with "not a valid script" error
203
+ expect(String(result)).not.toContain('valid script')
204
+ })
205
+
206
+ test('should accept bash shebang', async () => {
207
+ server.use(
208
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
209
+ return HttpResponse.text('#!/bin/bash\necho "hook"', { status: 200 })
210
+ }),
211
+ )
212
+
213
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
214
+
215
+ const effect = Effect.gen(function* () {
216
+ const service = yield* CommitHookService
217
+ yield* service.installHook()
218
+ }).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
219
+
220
+ const result = await Effect.runPromise(effect).catch((e) => e)
221
+
222
+ // Should not fail with "not a valid script" error
223
+ expect(String(result)).not.toContain('valid script')
224
+ })
225
+
226
+ test('should accept env shebang', async () => {
227
+ server.use(
228
+ http.get('https://test.gerrit.com/tools/hooks/commit-msg', () => {
229
+ return HttpResponse.text('#!/usr/bin/env sh\necho "hook"', { status: 200 })
230
+ }),
231
+ )
232
+
233
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
234
+
235
+ const effect = Effect.gen(function* () {
236
+ const service = yield* CommitHookService
237
+ yield* service.installHook()
238
+ }).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(mockConfigLayer))
239
+
240
+ const result = await Effect.runPromise(effect).catch((e) => e)
241
+
242
+ // Should not fail with "not a valid script" error
243
+ expect(String(result)).not.toContain('valid script')
244
+ })
245
+ })
246
+ })