@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,163 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import type { ChangeInfo } from '@/schemas/gerrit'
|
|
3
|
+
|
|
4
|
+
const mockChange: ChangeInfo = {
|
|
5
|
+
id: 'test-project~master~I123',
|
|
6
|
+
_number: 12345,
|
|
7
|
+
change_id: 'I123',
|
|
8
|
+
project: 'test-project',
|
|
9
|
+
branch: 'master',
|
|
10
|
+
subject: 'Test change to abandon',
|
|
11
|
+
status: 'NEW',
|
|
12
|
+
created: '2024-01-01 10:00:00.000000000',
|
|
13
|
+
updated: '2024-01-01 12:00:00.000000000',
|
|
14
|
+
owner: {
|
|
15
|
+
_account_id: 1000,
|
|
16
|
+
name: 'Test User',
|
|
17
|
+
email: 'test@example.com',
|
|
18
|
+
},
|
|
19
|
+
labels: {
|
|
20
|
+
'Code-Review': {
|
|
21
|
+
value: 0,
|
|
22
|
+
},
|
|
23
|
+
Verified: {
|
|
24
|
+
value: 0,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
work_in_progress: false,
|
|
28
|
+
submittable: false,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('abandon command', () => {
|
|
32
|
+
let mockFetch: ReturnType<typeof mock>
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
// Reset fetch mock for each test
|
|
36
|
+
mockFetch = mock(() =>
|
|
37
|
+
Promise.resolve({
|
|
38
|
+
ok: true,
|
|
39
|
+
status: 200,
|
|
40
|
+
text: () => Promise.resolve(')]}\n{}'),
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
global.fetch = mockFetch as unknown as typeof fetch
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should call abandon API endpoint with correct parameters', async () => {
|
|
47
|
+
// Mock successful responses
|
|
48
|
+
mockFetch
|
|
49
|
+
.mockResolvedValueOnce({
|
|
50
|
+
ok: true,
|
|
51
|
+
text: async () => `)]}'
|
|
52
|
+
{
|
|
53
|
+
"id": "test-project~master~I123",
|
|
54
|
+
"project": "test-project",
|
|
55
|
+
"branch": "master",
|
|
56
|
+
"change_id": "I123",
|
|
57
|
+
"subject": "Test change to abandon",
|
|
58
|
+
"status": "NEW",
|
|
59
|
+
"_number": 12345
|
|
60
|
+
}`,
|
|
61
|
+
})
|
|
62
|
+
.mockResolvedValueOnce({
|
|
63
|
+
ok: true,
|
|
64
|
+
text: async () => ')]}\n{}',
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Note: This is a unit test demonstrating the API calls
|
|
68
|
+
// Actual integration would require running the full command
|
|
69
|
+
// which we avoid to prevent hitting production
|
|
70
|
+
|
|
71
|
+
// Verify the mock setup
|
|
72
|
+
const response = await mockFetch('https://test.gerrit.com/a/changes/12345')
|
|
73
|
+
const text = await response.text()
|
|
74
|
+
expect(text).toContain('Test change to abandon')
|
|
75
|
+
|
|
76
|
+
// Verify abandon endpoint would be called
|
|
77
|
+
const abandonResponse = await mockFetch('https://test.gerrit.com/a/changes/12345/abandon', {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
body: JSON.stringify({ message: 'No longer needed' }),
|
|
80
|
+
})
|
|
81
|
+
expect(abandonResponse.ok).toBe(true)
|
|
82
|
+
|
|
83
|
+
// Verify calls were made
|
|
84
|
+
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('should handle abandon without message', async () => {
|
|
88
|
+
mockFetch.mockResolvedValueOnce({
|
|
89
|
+
ok: true,
|
|
90
|
+
text: async () => ')]}\n{}',
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const response = await mockFetch('https://test.gerrit.com/a/changes/12345/abandon', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
body: JSON.stringify({}),
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expect(response.ok).toBe(true)
|
|
99
|
+
expect(mockFetch).toHaveBeenCalledWith('https://test.gerrit.com/a/changes/12345/abandon', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
body: JSON.stringify({}),
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should handle API errors', async () => {
|
|
106
|
+
mockFetch.mockResolvedValueOnce({
|
|
107
|
+
ok: false,
|
|
108
|
+
status: 404,
|
|
109
|
+
text: async () => 'Change not found',
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const response = await mockFetch('https://test.gerrit.com/a/changes/99999/abandon')
|
|
113
|
+
expect(response.ok).toBe(false)
|
|
114
|
+
expect(response.status).toBe(404)
|
|
115
|
+
|
|
116
|
+
const errorText = await response.text()
|
|
117
|
+
expect(errorText).toBe('Change not found')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should format message correctly in request body', () => {
|
|
121
|
+
const testCases = [
|
|
122
|
+
{ input: undefined, expected: {} },
|
|
123
|
+
{ input: '', expected: {} },
|
|
124
|
+
{ input: 'Abandoning this change', expected: { message: 'Abandoning this change' } },
|
|
125
|
+
{ input: 'Multi\nline\nmessage', expected: { message: 'Multi\nline\nmessage' } },
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
for (const testCase of testCases) {
|
|
129
|
+
const body = testCase.input ? { message: testCase.input } : {}
|
|
130
|
+
expect(body).toEqual(testCase.expected)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('interactive mode API patterns', () => {
|
|
135
|
+
it('should fetch changes for interactive mode', async () => {
|
|
136
|
+
mockFetch.mockResolvedValueOnce({
|
|
137
|
+
ok: true,
|
|
138
|
+
text: async () => `)]}'\n[${JSON.stringify(mockChange)}]`,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Test the API call pattern for interactive mode
|
|
142
|
+
const response = await mockFetch(
|
|
143
|
+
'https://test.gerrit.com/a/changes/?q=owner:self+status:open',
|
|
144
|
+
)
|
|
145
|
+
const text = await response.text()
|
|
146
|
+
expect(text).toContain('Test change to abandon')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should handle empty changes list response', async () => {
|
|
150
|
+
mockFetch.mockResolvedValueOnce({
|
|
151
|
+
ok: true,
|
|
152
|
+
text: async () => ")]}'\n[]",
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const response = await mockFetch(
|
|
156
|
+
'https://test.gerrit.com/a/changes/?q=owner:self+status:open',
|
|
157
|
+
)
|
|
158
|
+
const text = await response.text()
|
|
159
|
+
const parsed = JSON.parse(text.replace(")]}'\n", ''))
|
|
160
|
+
expect(parsed).toEqual([])
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
|
2
|
+
import { Effect, Layer } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
AiService,
|
|
5
|
+
AiServiceError,
|
|
6
|
+
NoAiToolFoundError,
|
|
7
|
+
AiResponseParseError,
|
|
8
|
+
AiServiceLive,
|
|
9
|
+
} from '@/services/ai'
|
|
10
|
+
import { ConfigService } from '@/services/config'
|
|
11
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
12
|
+
|
|
13
|
+
describe('AI Service', () => {
|
|
14
|
+
describe('extractResponseTag', () => {
|
|
15
|
+
test('should extract content from response tags', async () => {
|
|
16
|
+
const input = `Some text before
|
|
17
|
+
<response>
|
|
18
|
+
This is the response content
|
|
19
|
+
</response>
|
|
20
|
+
Some text after`
|
|
21
|
+
|
|
22
|
+
const result = await Effect.runPromise(
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const service = yield* AiService
|
|
25
|
+
return yield* service.extractResponseTag(input)
|
|
26
|
+
}).pipe(
|
|
27
|
+
Effect.provide(
|
|
28
|
+
Layer.succeed(
|
|
29
|
+
AiService,
|
|
30
|
+
AiService.of({
|
|
31
|
+
detectAiTool: () =>
|
|
32
|
+
Effect.fail(new NoAiToolFoundError({ message: 'Not implemented' })),
|
|
33
|
+
extractResponseTag: (output: string) =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
|
|
36
|
+
|
|
37
|
+
if (!responseMatch || !responseMatch[1]) {
|
|
38
|
+
return yield* Effect.fail(
|
|
39
|
+
new AiResponseParseError({
|
|
40
|
+
message: 'No <response> tag found in AI output',
|
|
41
|
+
rawOutput: output,
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return responseMatch[1].trim()
|
|
47
|
+
}),
|
|
48
|
+
runPrompt: () => Effect.fail(new AiServiceError({ message: 'Not implemented' })),
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
expect(result).toBe('This is the response content')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('should handle case-insensitive response tags', async () => {
|
|
59
|
+
const input = `<RESPONSE>Content here</RESPONSE>`
|
|
60
|
+
|
|
61
|
+
const result = await Effect.runPromise(
|
|
62
|
+
Effect.gen(function* () {
|
|
63
|
+
const service = yield* AiService
|
|
64
|
+
return yield* service.extractResponseTag(input)
|
|
65
|
+
}).pipe(
|
|
66
|
+
Effect.provide(
|
|
67
|
+
Layer.succeed(
|
|
68
|
+
AiService,
|
|
69
|
+
AiService.of({
|
|
70
|
+
detectAiTool: () =>
|
|
71
|
+
Effect.fail(new NoAiToolFoundError({ message: 'Not implemented' })),
|
|
72
|
+
extractResponseTag: (output: string) =>
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
|
|
75
|
+
|
|
76
|
+
if (!responseMatch || !responseMatch[1]) {
|
|
77
|
+
return yield* Effect.fail(
|
|
78
|
+
new AiResponseParseError({
|
|
79
|
+
message: 'No <response> tag found in AI output',
|
|
80
|
+
rawOutput: output,
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return responseMatch[1].trim()
|
|
86
|
+
}),
|
|
87
|
+
runPrompt: () => Effect.fail(new AiServiceError({ message: 'Not implemented' })),
|
|
88
|
+
}),
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expect(result).toBe('Content here')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('should fail when no response tag is found', async () => {
|
|
98
|
+
const input = 'This is just plain text without tags'
|
|
99
|
+
|
|
100
|
+
const result = await Effect.runPromise(
|
|
101
|
+
Effect.either(
|
|
102
|
+
Effect.gen(function* () {
|
|
103
|
+
const service = yield* AiService
|
|
104
|
+
return yield* service.extractResponseTag(input)
|
|
105
|
+
}).pipe(
|
|
106
|
+
Effect.provide(
|
|
107
|
+
Layer.succeed(
|
|
108
|
+
AiService,
|
|
109
|
+
AiService.of({
|
|
110
|
+
detectAiTool: () =>
|
|
111
|
+
Effect.fail(new NoAiToolFoundError({ message: 'Not implemented' })),
|
|
112
|
+
extractResponseTag: (output: string) =>
|
|
113
|
+
Effect.gen(function* () {
|
|
114
|
+
const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
|
|
115
|
+
|
|
116
|
+
if (!responseMatch || !responseMatch[1]) {
|
|
117
|
+
return yield* Effect.fail(
|
|
118
|
+
new AiResponseParseError({
|
|
119
|
+
message: 'No <response> tag found in AI output',
|
|
120
|
+
rawOutput: output,
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return responseMatch[1].trim()
|
|
126
|
+
}),
|
|
127
|
+
runPrompt: () => Effect.fail(new AiServiceError({ message: 'Not implemented' })),
|
|
128
|
+
}),
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
expect(result._tag).toBe('Left')
|
|
136
|
+
if (result._tag === 'Left') {
|
|
137
|
+
expect(result.left).toBeInstanceOf(AiResponseParseError)
|
|
138
|
+
expect((result.left as AiResponseParseError).rawOutput).toBe(input)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('should handle multiline content in response tags', async () => {
|
|
143
|
+
const input = `<response>
|
|
144
|
+
Line 1
|
|
145
|
+
Line 2
|
|
146
|
+
Line 3
|
|
147
|
+
</response>`
|
|
148
|
+
|
|
149
|
+
const result = await Effect.runPromise(
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const service = yield* AiService
|
|
152
|
+
return yield* service.extractResponseTag(input)
|
|
153
|
+
}).pipe(
|
|
154
|
+
Effect.provide(
|
|
155
|
+
Layer.succeed(
|
|
156
|
+
AiService,
|
|
157
|
+
AiService.of({
|
|
158
|
+
detectAiTool: () =>
|
|
159
|
+
Effect.fail(new NoAiToolFoundError({ message: 'Not implemented' })),
|
|
160
|
+
extractResponseTag: (output: string) =>
|
|
161
|
+
Effect.gen(function* () {
|
|
162
|
+
const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
|
|
163
|
+
|
|
164
|
+
if (!responseMatch || !responseMatch[1]) {
|
|
165
|
+
return yield* Effect.fail(
|
|
166
|
+
new AiResponseParseError({
|
|
167
|
+
message: 'No <response> tag found in AI output',
|
|
168
|
+
rawOutput: output,
|
|
169
|
+
}),
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return responseMatch[1].trim()
|
|
174
|
+
}),
|
|
175
|
+
runPrompt: () => Effect.fail(new AiServiceError({ message: 'Not implemented' })),
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
expect(result).toBe('Line 1\nLine 2\nLine 3')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('Error Types', () => {
|
|
187
|
+
test('NoAiToolFoundError should have correct message', () => {
|
|
188
|
+
const error = new NoAiToolFoundError({
|
|
189
|
+
message: 'No AI tool found. Please install claude, llm, or opencode CLI.',
|
|
190
|
+
})
|
|
191
|
+
expect(error.message).toBe('No AI tool found. Please install claude, llm, or opencode CLI.')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('AiResponseParseError should include raw output', () => {
|
|
195
|
+
const error = new AiResponseParseError({
|
|
196
|
+
message: 'Failed to parse response',
|
|
197
|
+
rawOutput: 'Some raw output',
|
|
198
|
+
})
|
|
199
|
+
expect(error.message).toBe('Failed to parse response')
|
|
200
|
+
expect(error.rawOutput).toBe('Some raw output')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('AiServiceError should have message and optional cause', () => {
|
|
204
|
+
const cause = new Error('Original error')
|
|
205
|
+
const error = new AiServiceError({
|
|
206
|
+
message: 'Service failed',
|
|
207
|
+
cause,
|
|
208
|
+
})
|
|
209
|
+
expect(error.message).toBe('Service failed')
|
|
210
|
+
expect(error.cause).toBe(cause)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('detectAiTool', () => {
|
|
215
|
+
test('should detect claude tool when available', async () => {
|
|
216
|
+
// Mock which command to return success for claude
|
|
217
|
+
const mockExecAsync = mock(() =>
|
|
218
|
+
Promise.resolve({ stdout: '/usr/local/bin/claude\n', stderr: '' }),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const mockAiService = AiService.of({
|
|
222
|
+
detectAiTool: () => Effect.succeed('claude'),
|
|
223
|
+
extractResponseTag: (output: string) => Effect.succeed(output),
|
|
224
|
+
runPrompt: () => Effect.succeed('mock response'),
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const result = await Effect.runPromise(
|
|
228
|
+
Effect.gen(function* () {
|
|
229
|
+
const service = yield* AiService
|
|
230
|
+
return yield* service.detectAiTool()
|
|
231
|
+
}).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
expect(result).toBe('claude')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('should detect llm tool when claude not available', async () => {
|
|
238
|
+
const mockAiService = AiService.of({
|
|
239
|
+
detectAiTool: () => Effect.succeed('llm'),
|
|
240
|
+
extractResponseTag: (output: string) => Effect.succeed(output),
|
|
241
|
+
runPrompt: () => Effect.succeed('mock response'),
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const result = await Effect.runPromise(
|
|
245
|
+
Effect.gen(function* () {
|
|
246
|
+
const service = yield* AiService
|
|
247
|
+
return yield* service.detectAiTool()
|
|
248
|
+
}).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
expect(result).toBe('llm')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('should detect opencode tool when others not available', async () => {
|
|
255
|
+
const mockAiService = AiService.of({
|
|
256
|
+
detectAiTool: () => Effect.succeed('opencode'),
|
|
257
|
+
extractResponseTag: (output: string) => Effect.succeed(output),
|
|
258
|
+
runPrompt: () => Effect.succeed('mock response'),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const result = await Effect.runPromise(
|
|
262
|
+
Effect.gen(function* () {
|
|
263
|
+
const service = yield* AiService
|
|
264
|
+
return yield* service.detectAiTool()
|
|
265
|
+
}).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
expect(result).toBe('opencode')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('should detect gemini tool when others not available', async () => {
|
|
272
|
+
const mockAiService = AiService.of({
|
|
273
|
+
detectAiTool: () => Effect.succeed('gemini'),
|
|
274
|
+
extractResponseTag: (output: string) => Effect.succeed(output),
|
|
275
|
+
runPrompt: () => Effect.succeed('mock response'),
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const result = await Effect.runPromise(
|
|
279
|
+
Effect.gen(function* () {
|
|
280
|
+
const service = yield* AiService
|
|
281
|
+
return yield* service.detectAiTool()
|
|
282
|
+
}).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
expect(result).toBe('gemini')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('should fail when no AI tools are available', async () => {
|
|
289
|
+
const mockAiService = AiService.of({
|
|
290
|
+
detectAiTool: () =>
|
|
291
|
+
Effect.fail(
|
|
292
|
+
new NoAiToolFoundError({
|
|
293
|
+
message: 'No AI tool found. Please install claude, llm, opencode, or gemini CLI.',
|
|
294
|
+
}),
|
|
295
|
+
),
|
|
296
|
+
extractResponseTag: (output: string) => Effect.succeed(output),
|
|
297
|
+
runPrompt: () => Effect.succeed('mock response'),
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
const result = await Effect.runPromise(
|
|
301
|
+
Effect.either(
|
|
302
|
+
Effect.gen(function* () {
|
|
303
|
+
const service = yield* AiService
|
|
304
|
+
return yield* service.detectAiTool()
|
|
305
|
+
}).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
|
|
306
|
+
),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
expect(result._tag).toBe('Left')
|
|
310
|
+
if (result._tag === 'Left') {
|
|
311
|
+
expect(result.left).toBeInstanceOf(NoAiToolFoundError)
|
|
312
|
+
expect((result.left as NoAiToolFoundError).message).toContain('No AI tool found')
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
describe('runPrompt', () => {
|
|
318
|
+
test('should successfully run prompt with claude', async () => {
|
|
319
|
+
const mockAiService = AiService.of({
|
|
320
|
+
detectAiTool: () => Effect.succeed('claude'),
|
|
321
|
+
extractResponseTag: (output: string) => Effect.succeed('extracted response'),
|
|
322
|
+
runPrompt: (prompt: string, input: string) => {
|
|
323
|
+
expect(prompt).toBe('Test prompt')
|
|
324
|
+
expect(input).toBe('Test input')
|
|
325
|
+
return Effect.succeed('extracted response')
|
|
326
|
+
},
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
const result = await Effect.runPromise(
|
|
330
|
+
Effect.gen(function* () {
|
|
331
|
+
const service = yield* AiService
|
|
332
|
+
return yield* service.runPrompt('Test prompt', 'Test input')
|
|
333
|
+
}).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
expect(result).toBe('extracted response')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('should successfully run prompt with llm', async () => {
|
|
340
|
+
const mockAiService = AiService.of({
|
|
341
|
+
detectAiTool: () => Effect.succeed('llm'),
|
|
342
|
+
extractResponseTag: (output: string) => Effect.succeed('llm response'),
|
|
343
|
+
runPrompt: (prompt: string, input: string) => {
|
|
344
|
+
return Effect.succeed('llm response')
|
|
345
|
+
},
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
const result = await Effect.runPromise(
|
|
349
|
+
Effect.gen(function* () {
|
|
350
|
+
const service = yield* AiService
|
|
351
|
+
return yield* service.runPrompt('Test prompt', 'Test input')
|
|
352
|
+
}).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
expect(result).toBe('llm response')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('should handle AI tool execution failure', async () => {
|
|
359
|
+
const mockAiService = AiService.of({
|
|
360
|
+
detectAiTool: () => Effect.succeed('claude'),
|
|
361
|
+
extractResponseTag: (output: string) => Effect.succeed(output),
|
|
362
|
+
runPrompt: () =>
|
|
363
|
+
Effect.fail(
|
|
364
|
+
new AiServiceError({
|
|
365
|
+
message: 'Failed to run AI tool: Command not found',
|
|
366
|
+
cause: new Error('ENOENT'),
|
|
367
|
+
}),
|
|
368
|
+
),
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
const result = await Effect.runPromise(
|
|
372
|
+
Effect.either(
|
|
373
|
+
Effect.gen(function* () {
|
|
374
|
+
const service = yield* AiService
|
|
375
|
+
return yield* service.runPrompt('Test prompt', 'Test input')
|
|
376
|
+
}).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
expect(result._tag).toBe('Left')
|
|
381
|
+
if (result._tag === 'Left') {
|
|
382
|
+
expect(result.left).toBeInstanceOf(AiServiceError)
|
|
383
|
+
expect((result.left as AiServiceError).message).toContain('Failed to run AI tool')
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test('should handle missing response tag in AI output', async () => {
|
|
388
|
+
const mockAiService = AiService.of({
|
|
389
|
+
detectAiTool: () => Effect.succeed('claude'),
|
|
390
|
+
extractResponseTag: (output: string) =>
|
|
391
|
+
Effect.fail(
|
|
392
|
+
new AiResponseParseError({
|
|
393
|
+
message: 'No <response> tag found in AI output',
|
|
394
|
+
rawOutput: 'Raw AI output without response tags',
|
|
395
|
+
}),
|
|
396
|
+
),
|
|
397
|
+
runPrompt: () =>
|
|
398
|
+
Effect.fail(
|
|
399
|
+
new AiResponseParseError({
|
|
400
|
+
message: 'No <response> tag found in AI output',
|
|
401
|
+
rawOutput: 'Raw AI output without response tags',
|
|
402
|
+
}),
|
|
403
|
+
),
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
const result = await Effect.runPromise(
|
|
407
|
+
Effect.either(
|
|
408
|
+
Effect.gen(function* () {
|
|
409
|
+
const service = yield* AiService
|
|
410
|
+
return yield* service.runPrompt('Test prompt', 'Test input')
|
|
411
|
+
}).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
expect(result._tag).toBe('Left')
|
|
416
|
+
if (result._tag === 'Left') {
|
|
417
|
+
expect(result.left).toBeInstanceOf(AiResponseParseError)
|
|
418
|
+
expect((result.left as AiResponseParseError).rawOutput).toBe(
|
|
419
|
+
'Raw AI output without response tags',
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test('should handle no AI tool found during prompt execution', async () => {
|
|
425
|
+
const mockAiService = AiService.of({
|
|
426
|
+
detectAiTool: () =>
|
|
427
|
+
Effect.fail(
|
|
428
|
+
new NoAiToolFoundError({
|
|
429
|
+
message: 'No AI tool found',
|
|
430
|
+
}),
|
|
431
|
+
),
|
|
432
|
+
extractResponseTag: (output: string) => Effect.succeed(output),
|
|
433
|
+
runPrompt: () =>
|
|
434
|
+
Effect.fail(
|
|
435
|
+
new NoAiToolFoundError({
|
|
436
|
+
message: 'No AI tool found',
|
|
437
|
+
}),
|
|
438
|
+
),
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
const result = await Effect.runPromise(
|
|
442
|
+
Effect.either(
|
|
443
|
+
Effect.gen(function* () {
|
|
444
|
+
const service = yield* AiService
|
|
445
|
+
return yield* service.runPrompt('Test prompt', 'Test input')
|
|
446
|
+
}).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
|
|
447
|
+
),
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
expect(result._tag).toBe('Left')
|
|
451
|
+
if (result._tag === 'Left') {
|
|
452
|
+
expect(result.left).toBeInstanceOf(NoAiToolFoundError)
|
|
453
|
+
}
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
test('should format input correctly for AI tool', async () => {
|
|
457
|
+
const mockAiService = AiService.of({
|
|
458
|
+
detectAiTool: () => Effect.succeed('claude'),
|
|
459
|
+
extractResponseTag: (output: string) => Effect.succeed('response content'),
|
|
460
|
+
runPrompt: (prompt: string, input: string) => {
|
|
461
|
+
// Verify the prompt and input are passed correctly
|
|
462
|
+
expect(prompt).toBe('System: Analyze this code')
|
|
463
|
+
expect(input).toBe('function test() { return 42; }')
|
|
464
|
+
return Effect.succeed('response content')
|
|
465
|
+
},
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
const result = await Effect.runPromise(
|
|
469
|
+
Effect.gen(function* () {
|
|
470
|
+
const service = yield* AiService
|
|
471
|
+
return yield* service.runPrompt(
|
|
472
|
+
'System: Analyze this code',
|
|
473
|
+
'function test() { return 42; }',
|
|
474
|
+
)
|
|
475
|
+
}).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
expect(result).toBe('response content')
|
|
479
|
+
})
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
describe('AiServiceLive integration', () => {
|
|
483
|
+
test('should be able to create live service layer', () => {
|
|
484
|
+
// Test that the live service layer can be created without errors
|
|
485
|
+
expect(AiServiceLive).toBeDefined()
|
|
486
|
+
expect(typeof AiServiceLive).toBe('object')
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
})
|