@aaronshaf/ger 1.2.11 → 2.0.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/.claude-plugin/plugin.json +22 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +83 -0
- package/.github/workflows/claude.yml +50 -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 +105 -0
- package/DEVELOPMENT.md +361 -0
- package/EXAMPLES.md +457 -0
- package/README.md +831 -16
- package/bin/ger +3 -18
- package/biome.json +36 -0
- package/bun.lock +678 -0
- package/bunfig.toml +8 -0
- package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
- package/docs/adr/0002-use-bun-runtime.md +64 -0
- package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
- package/docs/adr/0004-use-commander-for-cli.md +76 -0
- package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
- package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
- package/docs/adr/0007-git-hooks-for-quality.md +94 -0
- package/docs/adr/0008-no-as-typecasting.md +83 -0
- package/docs/adr/0009-file-size-limits.md +82 -0
- package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
- package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
- package/docs/adr/0012-build-status-message-parsing.md +94 -0
- package/docs/adr/0013-git-subprocess-integration.md +98 -0
- package/docs/adr/0014-group-management-support.md +95 -0
- package/docs/adr/0015-batch-comment-processing.md +111 -0
- package/docs/adr/0016-flexible-change-identifiers.md +94 -0
- package/docs/adr/0017-git-worktree-support.md +102 -0
- package/docs/adr/0018-auto-install-commit-hook.md +103 -0
- package/docs/adr/0019-sdk-package-exports.md +95 -0
- package/docs/adr/0020-code-coverage-enforcement.md +105 -0
- package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
- package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
- package/docs/adr/README.md +30 -0
- package/docs/prd/README.md +12 -0
- package/docs/prd/architecture.md +325 -0
- package/docs/prd/commands.md +425 -0
- package/docs/prd/data-model.md +349 -0
- package/docs/prd/overview.md +124 -0
- package/index.ts +219 -0
- package/oxlint.json +24 -0
- package/package.json +82 -15
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/skills/gerrit-workflow/SKILL.md +247 -0
- package/skills/gerrit-workflow/examples.md +572 -0
- package/skills/gerrit-workflow/reference.md +728 -0
- package/src/api/gerrit.ts +696 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/add-reviewer.ts +156 -0
- package/src/cli/commands/build-status.ts +282 -0
- package/src/cli/commands/checkout.ts +422 -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/extract-url.ts +266 -0
- package/src/cli/commands/groups-members.ts +104 -0
- package/src/cli/commands/groups-show.ts +169 -0
- package/src/cli/commands/groups.ts +137 -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/projects.ts +68 -0
- package/src/cli/commands/push.ts +430 -0
- package/src/cli/commands/rebase.ts +52 -0
- package/src/cli/commands/remove-reviewer.ts +123 -0
- package/src/cli/commands/restore.ts +50 -0
- package/src/cli/commands/review.ts +486 -0
- package/src/cli/commands/search.ts +162 -0
- package/src/cli/commands/setup.ts +286 -0
- package/src/cli/commands/show.ts +491 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/submit.ts +108 -0
- package/src/cli/commands/vote.ts +119 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +53 -0
- package/src/cli/register-commands.ts +659 -0
- package/src/cli/register-group-commands.ts +88 -0
- package/src/cli/register-reviewer-commands.ts +97 -0
- package/src/prompts/default-review.md +86 -0
- package/src/prompts/system-inline-review.md +135 -0
- package/src/prompts/system-overall-review.md +206 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +84 -0
- package/src/schemas/gerrit.ts +681 -0
- package/src/services/commit-hook.ts +314 -0
- package/src/services/config.test.ts +150 -0
- package/src/services/config.ts +250 -0
- package/src/services/git-worktree.ts +342 -0
- package/src/services/review-strategy.ts +292 -0
- package/src/test-utils/mock-generator.ts +138 -0
- package/src/utils/change-id.test.ts +98 -0
- package/src/utils/change-id.ts +63 -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/git-commit.test.ts +277 -0
- package/src/utils/git-commit.ts +122 -0
- package/src/utils/index.ts +55 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/review-formatters.ts +89 -0
- package/src/utils/review-prompt-builder.ts +110 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +271 -0
- package/src/utils/url-parser.ts +118 -0
- package/tests/abandon.test.ts +230 -0
- package/tests/add-reviewer.test.ts +579 -0
- package/tests/build-status-watch.test.ts +344 -0
- package/tests/build-status.test.ts +789 -0
- package/tests/change-id-formats.test.ts +268 -0
- package/tests/checkout/integration.test.ts +653 -0
- package/tests/checkout/parse-input.test.ts +55 -0
- package/tests/checkout/validation.test.ts +178 -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 +708 -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/extract-url.test.ts +517 -0
- package/tests/groups-members.test.ts +256 -0
- package/tests/groups-show.test.ts +323 -0
- package/tests/groups.test.ts +334 -0
- package/tests/helpers/build-status-test-setup.ts +83 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/init.test.ts +70 -0
- package/tests/integration/commit-hook.test.ts +246 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +285 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/projects.test.ts +259 -0
- package/tests/rebase.test.ts +271 -0
- package/tests/remove-reviewer.test.ts +357 -0
- package/tests/restore.test.ts +237 -0
- package/tests/review.test.ts +135 -0
- package/tests/search.test.ts +712 -0
- package/tests/setup.test.ts +63 -0
- package/tests/show-auto-detect.test.ts +324 -0
- package/tests/show.test.ts +813 -0
- package/tests/status.test.ts +145 -0
- package/tests/submit.test.ts +316 -0
- package/tests/unit/commands/push.test.ts +194 -0
- package/tests/unit/git-branch-detection.test.ts +82 -0
- package/tests/unit/git-worktree.test.ts +55 -0
- package/tests/unit/patterns/push-patterns.test.ts +148 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/services/commit-hook.test.ts +132 -0
- package/tests/unit/services/review-strategy.test.ts +349 -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/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- package/tests/vote.test.ts +317 -0
- package/tests/workspace.test.ts +295 -0
- package/tsconfig.json +36 -5
- package/src/commands/branch.ts +0 -196
- package/src/ger.ts +0 -22
- package/src/types.d.ts +0 -35
- package/src/utils.ts +0 -130
|
@@ -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,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
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { test, expect, describe } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
getStatusIndicators,
|
|
4
|
+
getStatusString,
|
|
5
|
+
getLabelValue,
|
|
6
|
+
getLabelColor,
|
|
7
|
+
DEFAULT_STATUS_INDICATORS,
|
|
8
|
+
} from '@/utils/status-indicators'
|
|
9
|
+
import { generateMockChange } from '@/test-utils/mock-generator'
|
|
10
|
+
|
|
11
|
+
describe('Status Indicators Utility', () => {
|
|
12
|
+
describe('getStatusIndicators', () => {
|
|
13
|
+
test('should return approved indicators for approved changes', () => {
|
|
14
|
+
const change = generateMockChange({
|
|
15
|
+
labels: {
|
|
16
|
+
'Code-Review': { approved: { _account_id: 1 }, value: 2 },
|
|
17
|
+
Verified: { approved: { _account_id: 1 }, value: 1 },
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const indicators = getStatusIndicators(change)
|
|
22
|
+
expect(indicators).toEqual(['✓'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('should return rejected indicators for rejected changes', () => {
|
|
26
|
+
const change = generateMockChange({
|
|
27
|
+
labels: {
|
|
28
|
+
'Code-Review': { rejected: { _account_id: 1 }, value: -2 },
|
|
29
|
+
Verified: { rejected: { _account_id: 1 }, value: -1 },
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const indicators = getStatusIndicators(change)
|
|
34
|
+
expect(indicators).toEqual(['✗', '✗'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('should return recommended and disliked indicators', () => {
|
|
38
|
+
const change1 = generateMockChange({
|
|
39
|
+
labels: {
|
|
40
|
+
'Code-Review': { recommended: { _account_id: 1 }, value: 1 },
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const change2 = generateMockChange({
|
|
45
|
+
labels: {
|
|
46
|
+
'Code-Review': { disliked: { _account_id: 1 }, value: -1 },
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
expect(getStatusIndicators(change1)).toEqual(['↑'])
|
|
51
|
+
expect(getStatusIndicators(change2)).toEqual(['↓'])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('should handle empty labels', () => {
|
|
55
|
+
const change = generateMockChange({ labels: {} })
|
|
56
|
+
expect(getStatusIndicators(change)).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('should handle undefined labels', () => {
|
|
60
|
+
const change = generateMockChange({ labels: undefined })
|
|
61
|
+
expect(getStatusIndicators(change)).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('should use custom indicator config', () => {
|
|
65
|
+
const customConfig = {
|
|
66
|
+
...DEFAULT_STATUS_INDICATORS,
|
|
67
|
+
approved: '🟢',
|
|
68
|
+
rejected: '🔴',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const change = generateMockChange({
|
|
72
|
+
labels: {
|
|
73
|
+
'Code-Review': { approved: { _account_id: 1 }, value: 2 },
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const indicators = getStatusIndicators(change, customConfig)
|
|
78
|
+
expect(indicators).toEqual(['🟢'])
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('getStatusString', () => {
|
|
83
|
+
test('should return padded status string', () => {
|
|
84
|
+
const change = generateMockChange({
|
|
85
|
+
labels: {
|
|
86
|
+
'Code-Review': { recommended: { _account_id: 1 }, value: 1 },
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const statusString = getStatusString(change, undefined, 10)
|
|
91
|
+
expect(statusString).toBe('↑ ')
|
|
92
|
+
expect(statusString.length).toBe(10)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('should return empty padded string for no indicators', () => {
|
|
96
|
+
const change = generateMockChange({ labels: {} })
|
|
97
|
+
const statusString = getStatusString(change, undefined, 8)
|
|
98
|
+
expect(statusString).toBe(' ')
|
|
99
|
+
expect(statusString.length).toBe(8)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('getLabelValue', () => {
|
|
104
|
+
test('should extract numeric values correctly', () => {
|
|
105
|
+
expect(getLabelValue({ value: 2 })).toBe(2)
|
|
106
|
+
expect(getLabelValue({ value: -1 })).toBe(-1)
|
|
107
|
+
expect(getLabelValue({ value: 0 })).toBe(0)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('should return 0 for invalid inputs', () => {
|
|
111
|
+
expect(getLabelValue({})).toBe(0)
|
|
112
|
+
expect(getLabelValue({ notValue: 2 })).toBe(0)
|
|
113
|
+
expect(getLabelValue({ value: 'string' })).toBe(0)
|
|
114
|
+
expect(getLabelValue(null)).toBe(0)
|
|
115
|
+
expect(getLabelValue(undefined)).toBe(0)
|
|
116
|
+
expect(getLabelValue('string')).toBe(0)
|
|
117
|
+
expect(getLabelValue(123)).toBe(0)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('getLabelColor', () => {
|
|
122
|
+
test('should return correct colors for label values', () => {
|
|
123
|
+
expect(getLabelColor(2)).toBe('green')
|
|
124
|
+
expect(getLabelColor(1)).toBe('green')
|
|
125
|
+
expect(getLabelColor(0)).toBe('yellow')
|
|
126
|
+
expect(getLabelColor(-1)).toBe('red')
|
|
127
|
+
expect(getLabelColor(-2)).toBe('red')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('should handle edge cases', () => {
|
|
131
|
+
expect(getLabelColor(0.1)).toBe('green')
|
|
132
|
+
expect(getLabelColor(-0.1)).toBe('red')
|
|
133
|
+
expect(getLabelColor(100)).toBe('green')
|
|
134
|
+
expect(getLabelColor(-100)).toBe('red')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
})
|