@aaronshaf/ger 0.2.5 → 0.3.2
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/README.md +44 -6
- package/package.json +1 -1
- package/src/cli/commands/build-status.ts +135 -18
- package/src/cli/index.ts +38 -16
- package/tests/build-status-watch.test.ts +344 -0
- package/tests/build-status.test.ts +167 -69
- package/tests/helpers/build-status-test-setup.ts +83 -0
package/README.md
CHANGED
|
@@ -59,6 +59,10 @@ ger extract-url "build-summary-report" | tail -1
|
|
|
59
59
|
ger build-status 12345 # Returns: pending, running, success, failure, or not_found
|
|
60
60
|
ger build-status # Auto-detects from HEAD commit
|
|
61
61
|
|
|
62
|
+
# Watch build status until completion (like gh run watch)
|
|
63
|
+
ger build-status 12345 --watch
|
|
64
|
+
ger build-status --watch --exit-status && deploy.sh
|
|
65
|
+
|
|
62
66
|
# AI-powered code review (requires claude, llm, or opencode CLI)
|
|
63
67
|
ger review 12345
|
|
64
68
|
ger review 12345 --dry-run # Preview without posting
|
|
@@ -257,21 +261,48 @@ ger extract-url "job/[^/]+/job/[^/]+/\d+/$" --regex
|
|
|
257
261
|
|
|
258
262
|
Check the CI build status of a change by parsing Gerrit messages for build events and verification results:
|
|
259
263
|
|
|
264
|
+
#### Single Check (Snapshot)
|
|
260
265
|
```bash
|
|
261
266
|
# Check build status for a specific change
|
|
262
267
|
ger build-status 12345
|
|
268
|
+
# Output: {"state":"success"}
|
|
263
269
|
|
|
264
270
|
# Auto-detect change from HEAD commit
|
|
265
271
|
ger build-status
|
|
266
272
|
|
|
267
273
|
# Use in scripts with jq
|
|
268
274
|
ger build-status | jq -r '.state'
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
#### Watch Mode (Poll Until Completion)
|
|
278
|
+
Like `gh run watch`, you can poll the build status until it reaches a terminal state:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
# Watch until completion (outputs JSON on each poll)
|
|
282
|
+
ger build-status 12345 --watch
|
|
283
|
+
# Output:
|
|
284
|
+
# {"state":"pending"}
|
|
285
|
+
# {"state":"running"}
|
|
286
|
+
# {"state":"running"}
|
|
287
|
+
# {"state":"success"}
|
|
288
|
+
|
|
289
|
+
# Auto-detect from HEAD commit
|
|
290
|
+
ger build-status --watch
|
|
269
291
|
|
|
270
|
-
#
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
292
|
+
# Custom polling interval (check every 5 seconds, default: 10)
|
|
293
|
+
ger build-status --watch --interval 5
|
|
294
|
+
|
|
295
|
+
# Custom timeout (60 minutes, default: 30 minutes)
|
|
296
|
+
ger build-status --watch --timeout 3600
|
|
297
|
+
|
|
298
|
+
# Exit with code 1 on build failure (for CI/CD pipelines)
|
|
299
|
+
ger build-status --watch --exit-status && deploy.sh
|
|
300
|
+
|
|
301
|
+
# Trigger notification when done (like gh run watch pattern)
|
|
302
|
+
ger build-status --watch && notify-send 'Build is done!'
|
|
303
|
+
|
|
304
|
+
# Extract final state in scripts
|
|
305
|
+
ger build-status --watch | tail -1 | jq -r '.state'
|
|
275
306
|
```
|
|
276
307
|
|
|
277
308
|
#### Output format (JSON):
|
|
@@ -286,17 +317,24 @@ done
|
|
|
286
317
|
- **`failure`**: Verified -1 after most recent "Build Started"
|
|
287
318
|
- **`not_found`**: Change does not exist
|
|
288
319
|
|
|
320
|
+
#### Exit codes:
|
|
321
|
+
- **`0`**: Default for all states (like `gh run watch`)
|
|
322
|
+
- **`1`**: Only when `--exit-status` flag is used AND build fails
|
|
323
|
+
- **`2`**: Timeout reached in watch mode
|
|
324
|
+
- **`3`**: API/network errors
|
|
325
|
+
|
|
289
326
|
#### How it works:
|
|
290
327
|
1. Fetches all messages for the change
|
|
291
328
|
2. Finds the most recent "Build Started" message
|
|
292
329
|
3. Checks for "Verified +1" or "Verified -1" messages after the build started
|
|
293
330
|
4. Returns the appropriate state
|
|
331
|
+
5. In watch mode: polls every N seconds until terminal state or timeout
|
|
294
332
|
|
|
295
333
|
#### Use cases:
|
|
296
334
|
- **CI/CD integration**: Wait for builds before proceeding with deployment
|
|
297
335
|
- **Automation**: Trigger actions based on build results
|
|
298
336
|
- **Scripting**: Check build status in shell scripts
|
|
299
|
-
- **Monitoring**: Poll build status for long-running builds
|
|
337
|
+
- **Monitoring**: Poll build status for long-running builds with watch mode
|
|
300
338
|
|
|
301
339
|
### Diff
|
|
302
340
|
```bash
|
package/package.json
CHANGED
|
@@ -6,6 +6,23 @@ import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-comm
|
|
|
6
6
|
// Export types for external use
|
|
7
7
|
export type BuildState = 'pending' | 'running' | 'success' | 'failure' | 'not_found'
|
|
8
8
|
|
|
9
|
+
// Watch options (matches gh run watch pattern)
|
|
10
|
+
export type WatchOptions = {
|
|
11
|
+
readonly watch: boolean
|
|
12
|
+
readonly interval: number // seconds
|
|
13
|
+
readonly timeout: number // seconds
|
|
14
|
+
readonly exitStatus: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Timeout error for watch mode
|
|
18
|
+
export class TimeoutError extends Error {
|
|
19
|
+
readonly _tag = 'TimeoutError'
|
|
20
|
+
constructor(message: string) {
|
|
21
|
+
super(message)
|
|
22
|
+
this.name = 'TimeoutError'
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
9
26
|
// Effect Schema for BuildStatus (follows project patterns)
|
|
10
27
|
export const BuildStatus: Schema.Schema<{
|
|
11
28
|
readonly state: 'pending' | 'running' | 'success' | 'failure' | 'not_found'
|
|
@@ -20,7 +37,8 @@ const VERIFIED_PLUS_PATTERN = /Verified\s*[+]\s*1/
|
|
|
20
37
|
const VERIFIED_MINUS_PATTERN = /Verified\s*[-]\s*1/
|
|
21
38
|
|
|
22
39
|
/**
|
|
23
|
-
* Parse messages to determine build status based on "Build Started" and verification messages
|
|
40
|
+
* Parse messages to determine build status based on "Build Started" and verification messages.
|
|
41
|
+
* Only considers verification messages for the same patchset as the latest build.
|
|
24
42
|
*/
|
|
25
43
|
const parseBuildStatus = (messages: readonly MessageInfo[]): BuildStatus => {
|
|
26
44
|
// Empty messages means change exists but has no activity yet - return pending
|
|
@@ -28,11 +46,13 @@ const parseBuildStatus = (messages: readonly MessageInfo[]): BuildStatus => {
|
|
|
28
46
|
return { state: 'pending' }
|
|
29
47
|
}
|
|
30
48
|
|
|
31
|
-
// Find the most recent "Build Started" message
|
|
49
|
+
// Find the most recent "Build Started" message and its revision number
|
|
32
50
|
let lastBuildDate: string | null = null
|
|
51
|
+
let lastBuildRevision: number | undefined = undefined
|
|
33
52
|
for (const msg of messages) {
|
|
34
53
|
if (BUILD_STARTED_PATTERN.test(msg.message)) {
|
|
35
54
|
lastBuildDate = msg.date
|
|
55
|
+
lastBuildRevision = msg._revision_number
|
|
36
56
|
}
|
|
37
57
|
}
|
|
38
58
|
|
|
@@ -41,12 +61,18 @@ const parseBuildStatus = (messages: readonly MessageInfo[]): BuildStatus => {
|
|
|
41
61
|
return { state: 'pending' }
|
|
42
62
|
}
|
|
43
63
|
|
|
44
|
-
// Check for verification messages after the build started
|
|
64
|
+
// Check for verification messages after the build started AND for the same revision
|
|
45
65
|
for (const msg of messages) {
|
|
46
66
|
const date = msg.date
|
|
47
67
|
// Gerrit timestamps are ISO 8601 strings (lexicographically sortable)
|
|
48
68
|
if (date <= lastBuildDate) continue
|
|
49
69
|
|
|
70
|
+
// Only consider verification messages for the same patchset
|
|
71
|
+
// If revision numbers are available, they must match
|
|
72
|
+
if (lastBuildRevision !== undefined && msg._revision_number !== undefined) {
|
|
73
|
+
if (msg._revision_number !== lastBuildRevision) continue
|
|
74
|
+
}
|
|
75
|
+
|
|
50
76
|
if (VERIFIED_PLUS_PATTERN.test(msg.message)) {
|
|
51
77
|
return { state: 'success' }
|
|
52
78
|
} else if (VERIFIED_MINUS_PATTERN.test(msg.message)) {
|
|
@@ -71,38 +97,129 @@ const getMessagesForChange = (
|
|
|
71
97
|
})
|
|
72
98
|
|
|
73
99
|
/**
|
|
74
|
-
*
|
|
100
|
+
* Poll build status until terminal state or timeout
|
|
101
|
+
* Outputs JSON status on each iteration (mimics gh run watch)
|
|
102
|
+
*/
|
|
103
|
+
const pollBuildStatus = (
|
|
104
|
+
changeId: string,
|
|
105
|
+
options: WatchOptions,
|
|
106
|
+
): Effect.Effect<BuildStatus, ApiError | TimeoutError, GerritApiService> =>
|
|
107
|
+
Effect.gen(function* () {
|
|
108
|
+
const startTime = Date.now()
|
|
109
|
+
const timeoutMs = options.timeout * 1000
|
|
110
|
+
|
|
111
|
+
while (true) {
|
|
112
|
+
// Check timeout
|
|
113
|
+
const elapsed = Date.now() - startTime
|
|
114
|
+
if (elapsed > timeoutMs) {
|
|
115
|
+
yield* Effect.sync(() => {
|
|
116
|
+
console.error(`Timeout: Build status check exceeded ${options.timeout}s`)
|
|
117
|
+
})
|
|
118
|
+
yield* Effect.fail(
|
|
119
|
+
new TimeoutError(`Build status check timed out after ${options.timeout}s`),
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fetch and parse status
|
|
124
|
+
const messages = yield* getMessagesForChange(changeId)
|
|
125
|
+
const status = parseBuildStatus(messages)
|
|
126
|
+
|
|
127
|
+
// Check timeout again after API call (in case it took longer than expected)
|
|
128
|
+
const elapsedAfterFetch = Date.now() - startTime
|
|
129
|
+
if (elapsedAfterFetch > timeoutMs) {
|
|
130
|
+
yield* Effect.sync(() => {
|
|
131
|
+
console.error(`Timeout: Build status check exceeded ${options.timeout}s`)
|
|
132
|
+
})
|
|
133
|
+
yield* Effect.fail(
|
|
134
|
+
new TimeoutError(`Build status check timed out after ${options.timeout}s`),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Output current status to stdout (JSON, like single-check mode)
|
|
139
|
+
yield* Effect.sync(() => {
|
|
140
|
+
process.stdout.write(JSON.stringify(status) + '\n')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// Terminal states - wait for interval before returning to allow logs to be written
|
|
144
|
+
if (status.state === 'success' || status.state === 'not_found') {
|
|
145
|
+
return status
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (status.state === 'failure') {
|
|
149
|
+
// Wait for interval seconds to allow build failure logs to be fully written
|
|
150
|
+
yield* Effect.sleep(options.interval * 1000)
|
|
151
|
+
return status
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Non-terminal states - sleep for interval duration
|
|
155
|
+
yield* Effect.sleep(options.interval * 1000)
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build status command with optional watch mode (mimics gh run watch)
|
|
75
161
|
*/
|
|
76
162
|
export const buildStatusCommand = (
|
|
77
163
|
changeId: string | undefined,
|
|
78
|
-
|
|
164
|
+
options: Partial<WatchOptions> = {},
|
|
165
|
+
): Effect.Effect<
|
|
166
|
+
void,
|
|
167
|
+
ApiError | Error | GitError | NoChangeIdError | TimeoutError,
|
|
168
|
+
GerritApiService
|
|
169
|
+
> =>
|
|
79
170
|
Effect.gen(function* () {
|
|
80
171
|
// Auto-detect Change-ID from HEAD commit if not provided
|
|
81
172
|
const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
|
|
82
173
|
|
|
83
|
-
//
|
|
84
|
-
const
|
|
174
|
+
// Set defaults (matching gh run watch patterns)
|
|
175
|
+
const watchOpts: WatchOptions = {
|
|
176
|
+
watch: options.watch ?? false,
|
|
177
|
+
interval: Math.max(1, options.interval ?? 10), // Min 1 second
|
|
178
|
+
timeout: Math.max(1, options.timeout ?? 1800), // Min 1 second, default 30 minutes
|
|
179
|
+
exitStatus: options.exitStatus ?? false,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let status: BuildStatus
|
|
85
183
|
|
|
86
|
-
|
|
87
|
-
|
|
184
|
+
if (watchOpts.watch) {
|
|
185
|
+
// Polling mode - outputs JSON on each iteration
|
|
186
|
+
status = yield* pollBuildStatus(resolvedChangeId, watchOpts)
|
|
187
|
+
} else {
|
|
188
|
+
// Single check mode (existing behavior)
|
|
189
|
+
const messages = yield* getMessagesForChange(resolvedChangeId)
|
|
190
|
+
status = parseBuildStatus(messages)
|
|
88
191
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
192
|
+
// Output JSON to stdout
|
|
193
|
+
yield* Effect.sync(() => {
|
|
194
|
+
process.stdout.write(JSON.stringify(status) + '\n')
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Handle exit codes (only non-zero when explicitly requested)
|
|
199
|
+
if (watchOpts.exitStatus && status.state === 'failure') {
|
|
200
|
+
yield* Effect.sync(() => process.exit(1))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Default: exit 0 for all states (success, failure, pending, etc.)
|
|
94
204
|
}).pipe(
|
|
95
|
-
// Error handling - return not_found for API errors (e.g., change not found)
|
|
96
205
|
Effect.catchAll((error) => {
|
|
206
|
+
// Timeout error
|
|
207
|
+
if (error instanceof TimeoutError) {
|
|
208
|
+
return Effect.sync(() => {
|
|
209
|
+
console.error(`Error: ${error.message}`)
|
|
210
|
+
process.exit(2)
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 404 - change not found
|
|
97
215
|
if (error && typeof error === 'object' && 'status' in error && error.status === 404) {
|
|
98
|
-
// Change not found - output not_found state and exit successfully
|
|
99
216
|
const status: BuildStatus = { state: 'not_found' }
|
|
100
217
|
return Effect.sync(() => {
|
|
101
218
|
process.stdout.write(JSON.stringify(status) + '\n')
|
|
102
219
|
})
|
|
103
220
|
}
|
|
104
221
|
|
|
105
|
-
//
|
|
222
|
+
// Other errors - exit 3
|
|
106
223
|
const errorMessage =
|
|
107
224
|
error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
|
|
108
225
|
? error.message
|
|
@@ -110,7 +227,7 @@ export const buildStatusCommand = (
|
|
|
110
227
|
|
|
111
228
|
return Effect.sync(() => {
|
|
112
229
|
console.error(`Error: ${errorMessage}`)
|
|
113
|
-
process.exit(
|
|
230
|
+
process.exit(3)
|
|
114
231
|
})
|
|
115
232
|
}),
|
|
116
233
|
)
|
package/src/cli/index.ts
CHANGED
|
@@ -426,6 +426,10 @@ program
|
|
|
426
426
|
.description(
|
|
427
427
|
'Check build status from Gerrit messages (auto-detects from HEAD commit if not specified)',
|
|
428
428
|
)
|
|
429
|
+
.option('--watch', 'Watch build status until completion (mimics gh run watch)')
|
|
430
|
+
.option('-i, --interval <seconds>', 'Refresh interval in seconds (default: 10)', '10')
|
|
431
|
+
.option('--timeout <seconds>', 'Maximum wait time in seconds (default: 1800 / 30min)', '1800')
|
|
432
|
+
.option('--exit-status', 'Exit with non-zero status if build fails')
|
|
429
433
|
.addHelpText(
|
|
430
434
|
'after',
|
|
431
435
|
`
|
|
@@ -439,40 +443,58 @@ Output is JSON with a "state" field that can be:
|
|
|
439
443
|
- failure: Build completed with Verified-1
|
|
440
444
|
- not_found: Change does not exist
|
|
441
445
|
|
|
446
|
+
Exit codes:
|
|
447
|
+
- 0: Default for all states (like gh run watch)
|
|
448
|
+
- 1: Only when --exit-status is used AND build fails
|
|
449
|
+
- 2: Timeout reached in watch mode
|
|
450
|
+
- 3: API/network errors
|
|
451
|
+
|
|
442
452
|
Examples:
|
|
443
|
-
#
|
|
453
|
+
# Single check (current behavior)
|
|
444
454
|
$ ger build-status 392385
|
|
445
455
|
{"state":"success"}
|
|
446
456
|
|
|
447
|
-
#
|
|
448
|
-
$ ger build-status
|
|
457
|
+
# Watch until completion (outputs JSON on each poll)
|
|
458
|
+
$ ger build-status 392385 --watch
|
|
459
|
+
{"state":"pending"}
|
|
460
|
+
{"state":"running"}
|
|
449
461
|
{"state":"running"}
|
|
462
|
+
{"state":"success"}
|
|
450
463
|
|
|
451
|
-
#
|
|
452
|
-
$ ger build-status
|
|
453
|
-
|
|
464
|
+
# Watch with custom interval (check every 5 seconds)
|
|
465
|
+
$ ger build-status --watch --interval 5
|
|
466
|
+
|
|
467
|
+
# Watch with custom timeout (60 minutes)
|
|
468
|
+
$ ger build-status --watch --timeout 3600
|
|
454
469
|
|
|
455
|
-
#
|
|
456
|
-
$
|
|
457
|
-
|
|
458
|
-
|
|
470
|
+
# Exit with code 1 on failure (for CI/CD pipelines)
|
|
471
|
+
$ ger build-status --watch --exit-status && deploy.sh
|
|
472
|
+
|
|
473
|
+
# Trigger notification when done (like gh run watch pattern)
|
|
474
|
+
$ ger build-status --watch && notify-send 'Build is done!'
|
|
475
|
+
|
|
476
|
+
# Parse final state in scripts
|
|
477
|
+
$ ger build-status --watch | tail -1 | jq -r '.state'
|
|
478
|
+
success
|
|
459
479
|
|
|
460
480
|
Note: When no change-id is provided, it will be automatically extracted from the
|
|
461
481
|
Change-ID footer in your HEAD commit.`,
|
|
462
482
|
)
|
|
463
|
-
.action(async (changeId) => {
|
|
483
|
+
.action(async (changeId, cmdOptions) => {
|
|
464
484
|
try {
|
|
465
|
-
const effect = buildStatusCommand(changeId
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
485
|
+
const effect = buildStatusCommand(changeId, {
|
|
486
|
+
watch: cmdOptions.watch,
|
|
487
|
+
interval: Number.parseInt(cmdOptions.interval, 10),
|
|
488
|
+
timeout: Number.parseInt(cmdOptions.timeout, 10),
|
|
489
|
+
exitStatus: cmdOptions.exitStatus,
|
|
490
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive))
|
|
469
491
|
await Effect.runPromise(effect)
|
|
470
492
|
} catch (error) {
|
|
471
493
|
// Errors are handled within the command itself
|
|
472
494
|
// This catch is just for any unexpected errors
|
|
473
495
|
if (error instanceof Error && error.message !== 'Process exited') {
|
|
474
496
|
console.error('✗ Unexpected error:', error.message)
|
|
475
|
-
process.exit(
|
|
497
|
+
process.exit(3)
|
|
476
498
|
}
|
|
477
499
|
}
|
|
478
500
|
})
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll, afterEach } from 'bun:test'
|
|
2
|
+
import { http, HttpResponse } from 'msw'
|
|
3
|
+
import { Effect } from 'effect'
|
|
4
|
+
import { buildStatusCommand } from '@/cli/commands/build-status'
|
|
5
|
+
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
6
|
+
import type { MessageInfo } from '@/schemas/gerrit'
|
|
7
|
+
import {
|
|
8
|
+
server,
|
|
9
|
+
capturedStdout,
|
|
10
|
+
capturedErrors,
|
|
11
|
+
mockProcessExit,
|
|
12
|
+
setupBuildStatusTests,
|
|
13
|
+
teardownBuildStatusTests,
|
|
14
|
+
resetBuildStatusMocks,
|
|
15
|
+
createMockConfigLayer,
|
|
16
|
+
} from './helpers/build-status-test-setup'
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
setupBuildStatusTests()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterAll(() => {
|
|
23
|
+
teardownBuildStatusTests()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
resetBuildStatusMocks()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('build-status command - watch mode', () => {
|
|
31
|
+
test('polls until success state is reached', async () => {
|
|
32
|
+
let callCount = 0
|
|
33
|
+
|
|
34
|
+
server.use(
|
|
35
|
+
http.get('*/a/changes/12345', ({ request }) => {
|
|
36
|
+
const url = new URL(request.url)
|
|
37
|
+
if (url.searchParams.get('o') === 'MESSAGES') {
|
|
38
|
+
callCount++
|
|
39
|
+
|
|
40
|
+
let messages: MessageInfo[]
|
|
41
|
+
if (callCount === 1) {
|
|
42
|
+
// First call: pending (no build started)
|
|
43
|
+
messages = []
|
|
44
|
+
} else if (callCount === 2) {
|
|
45
|
+
// Second call: running (build started, no verification)
|
|
46
|
+
messages = [
|
|
47
|
+
{
|
|
48
|
+
id: 'msg1',
|
|
49
|
+
message: 'Build Started',
|
|
50
|
+
date: '2024-01-15 10:00:00.000000000',
|
|
51
|
+
author: { _account_id: 9999, name: 'CI Bot' },
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
} else {
|
|
55
|
+
// Third call: success (verified +1)
|
|
56
|
+
messages = [
|
|
57
|
+
{
|
|
58
|
+
id: 'msg1',
|
|
59
|
+
message: 'Build Started',
|
|
60
|
+
date: '2024-01-15 10:00:00.000000000',
|
|
61
|
+
author: { _account_id: 9999, name: 'CI Bot' },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'msg2',
|
|
65
|
+
message: 'Patch Set 1: Verified+1',
|
|
66
|
+
date: '2024-01-15 10:05:00.000000000',
|
|
67
|
+
author: { _account_id: 9999, name: 'CI Bot' },
|
|
68
|
+
},
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return HttpResponse.json(
|
|
73
|
+
{ messages },
|
|
74
|
+
{ headers: { 'Content-Type': 'application/json' } },
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const effect = buildStatusCommand('12345', {
|
|
82
|
+
watch: true,
|
|
83
|
+
interval: 0.1, // Fast polling for tests
|
|
84
|
+
timeout: 10,
|
|
85
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
|
|
86
|
+
|
|
87
|
+
await Effect.runPromise(effect)
|
|
88
|
+
|
|
89
|
+
// Should have multiple outputs (one per poll)
|
|
90
|
+
expect(capturedStdout.length).toBeGreaterThanOrEqual(3)
|
|
91
|
+
expect(JSON.parse(capturedStdout[0])).toEqual({ state: 'pending' })
|
|
92
|
+
expect(JSON.parse(capturedStdout[1])).toEqual({ state: 'running' })
|
|
93
|
+
expect(JSON.parse(capturedStdout[2])).toEqual({ state: 'success' })
|
|
94
|
+
|
|
95
|
+
// Minimalistic output: no stderr messages except on timeout/error
|
|
96
|
+
expect(capturedErrors.length).toBe(0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('polls until failure state is reached', async () => {
|
|
100
|
+
let callCount = 0
|
|
101
|
+
|
|
102
|
+
server.use(
|
|
103
|
+
http.get('*/a/changes/12345', ({ request }) => {
|
|
104
|
+
const url = new URL(request.url)
|
|
105
|
+
if (url.searchParams.get('o') === 'MESSAGES') {
|
|
106
|
+
callCount++
|
|
107
|
+
|
|
108
|
+
let messages: MessageInfo[]
|
|
109
|
+
if (callCount === 1) {
|
|
110
|
+
messages = [
|
|
111
|
+
{
|
|
112
|
+
id: 'msg1',
|
|
113
|
+
message: 'Build Started',
|
|
114
|
+
date: '2024-01-15 10:00:00.000000000',
|
|
115
|
+
author: { _account_id: 9999, name: 'CI Bot' },
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
} else {
|
|
119
|
+
messages = [
|
|
120
|
+
{
|
|
121
|
+
id: 'msg1',
|
|
122
|
+
message: 'Build Started',
|
|
123
|
+
date: '2024-01-15 10:00:00.000000000',
|
|
124
|
+
author: { _account_id: 9999, name: 'CI Bot' },
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'msg2',
|
|
128
|
+
message: 'Patch Set 1: Verified-1',
|
|
129
|
+
date: '2024-01-15 10:05:00.000000000',
|
|
130
|
+
author: { _account_id: 9999, name: 'CI Bot' },
|
|
131
|
+
},
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return HttpResponse.json(
|
|
136
|
+
{ messages },
|
|
137
|
+
{ headers: { 'Content-Type': 'application/json' } },
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
141
|
+
}),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const effect = buildStatusCommand('12345', {
|
|
145
|
+
watch: true,
|
|
146
|
+
interval: 0.1,
|
|
147
|
+
timeout: 10,
|
|
148
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
|
|
149
|
+
|
|
150
|
+
await Effect.runPromise(effect)
|
|
151
|
+
|
|
152
|
+
expect(capturedStdout.length).toBeGreaterThanOrEqual(2)
|
|
153
|
+
expect(JSON.parse(capturedStdout[capturedStdout.length - 1])).toEqual({ state: 'failure' })
|
|
154
|
+
|
|
155
|
+
// Minimalistic output: no stderr messages except on timeout/error
|
|
156
|
+
expect(capturedErrors.length).toBe(0)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('times out after specified duration', async () => {
|
|
160
|
+
server.use(
|
|
161
|
+
http.get('*/a/changes/12345', ({ request }) => {
|
|
162
|
+
const url = new URL(request.url)
|
|
163
|
+
if (url.searchParams.get('o') === 'MESSAGES') {
|
|
164
|
+
// Always return running state
|
|
165
|
+
return HttpResponse.json(
|
|
166
|
+
{
|
|
167
|
+
messages: [
|
|
168
|
+
{
|
|
169
|
+
id: 'msg1',
|
|
170
|
+
message: 'Build Started',
|
|
171
|
+
date: '2024-01-15 10:00:00.000000000',
|
|
172
|
+
author: { _account_id: 9999, name: 'CI Bot' },
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
{ headers: { 'Content-Type': 'application/json' } },
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
180
|
+
}),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
const effect = buildStatusCommand('12345', {
|
|
184
|
+
watch: true,
|
|
185
|
+
interval: 0.1,
|
|
186
|
+
timeout: 0.5, // Very short timeout
|
|
187
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await Effect.runPromise(effect)
|
|
191
|
+
} catch {
|
|
192
|
+
// Should exit with code 2 for timeout
|
|
193
|
+
expect(mockProcessExit).toHaveBeenCalledWith(2)
|
|
194
|
+
expect(capturedErrors.some((e: string) => e.includes('Timeout'))).toBe(true)
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('exit-status flag causes exit 1 on failure', async () => {
|
|
199
|
+
server.use(
|
|
200
|
+
http.get('*/a/changes/12345', ({ request }) => {
|
|
201
|
+
const url = new URL(request.url)
|
|
202
|
+
if (url.searchParams.get('o') === 'MESSAGES') {
|
|
203
|
+
return HttpResponse.json(
|
|
204
|
+
{
|
|
205
|
+
messages: [
|
|
206
|
+
{
|
|
207
|
+
id: 'msg1',
|
|
208
|
+
message: 'Build Started',
|
|
209
|
+
date: '2024-01-15 10:00:00.000000000',
|
|
210
|
+
author: { _account_id: 9999, name: 'CI Bot' },
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: 'msg2',
|
|
214
|
+
message: 'Patch Set 1: Verified-1',
|
|
215
|
+
date: '2024-01-15 10:05:00.000000000',
|
|
216
|
+
author: { _account_id: 9999, name: 'CI Bot' },
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
{ headers: { 'Content-Type': 'application/json' } },
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
const effect = buildStatusCommand('12345', {
|
|
228
|
+
watch: true,
|
|
229
|
+
interval: 0.1,
|
|
230
|
+
timeout: 10,
|
|
231
|
+
exitStatus: true,
|
|
232
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
await Effect.runPromise(effect)
|
|
236
|
+
} catch {
|
|
237
|
+
// Should exit with code 1 for failure when --exit-status is used
|
|
238
|
+
expect(mockProcessExit).toHaveBeenCalledWith(1)
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('exit-status flag does not affect success state', async () => {
|
|
243
|
+
server.use(
|
|
244
|
+
http.get('*/a/changes/12345', ({ request }) => {
|
|
245
|
+
const url = new URL(request.url)
|
|
246
|
+
if (url.searchParams.get('o') === 'MESSAGES') {
|
|
247
|
+
return HttpResponse.json(
|
|
248
|
+
{
|
|
249
|
+
messages: [
|
|
250
|
+
{
|
|
251
|
+
id: 'msg1',
|
|
252
|
+
message: 'Build Started',
|
|
253
|
+
date: '2024-01-15 10:00:00.000000000',
|
|
254
|
+
author: { _account_id: 9999, name: 'CI Bot' },
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: 'msg2',
|
|
258
|
+
message: 'Patch Set 1: Verified+1',
|
|
259
|
+
date: '2024-01-15 10:05:00.000000000',
|
|
260
|
+
author: { _account_id: 9999, name: 'CI Bot' },
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
{ headers: { 'Content-Type': 'application/json' } },
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
const effect = buildStatusCommand('12345', {
|
|
272
|
+
watch: true,
|
|
273
|
+
interval: 0.1,
|
|
274
|
+
timeout: 10,
|
|
275
|
+
exitStatus: true,
|
|
276
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
|
|
277
|
+
|
|
278
|
+
await Effect.runPromise(effect)
|
|
279
|
+
|
|
280
|
+
// Should not call process.exit for success state
|
|
281
|
+
expect(mockProcessExit).not.toHaveBeenCalled()
|
|
282
|
+
expect(JSON.parse(capturedStdout[0])).toEqual({ state: 'success' })
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('watch mode handles not_found state', async () => {
|
|
286
|
+
server.use(
|
|
287
|
+
http.get('*/a/changes/99999', () => {
|
|
288
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
289
|
+
}),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
const effect = buildStatusCommand('99999', {
|
|
293
|
+
watch: true,
|
|
294
|
+
interval: 0.1,
|
|
295
|
+
timeout: 10,
|
|
296
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
|
|
297
|
+
|
|
298
|
+
await Effect.runPromise(effect)
|
|
299
|
+
|
|
300
|
+
expect(capturedStdout.length).toBe(1)
|
|
301
|
+
expect(JSON.parse(capturedStdout[0])).toEqual({ state: 'not_found' })
|
|
302
|
+
|
|
303
|
+
// 404 errors bypass pollBuildStatus and are handled in error handler
|
|
304
|
+
// Minimalistic output: no stderr messages for not_found state
|
|
305
|
+
expect(capturedErrors.length).toBe(0)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('without watch flag, behaves as single check', async () => {
|
|
309
|
+
server.use(
|
|
310
|
+
http.get('*/a/changes/12345', ({ request }) => {
|
|
311
|
+
const url = new URL(request.url)
|
|
312
|
+
if (url.searchParams.get('o') === 'MESSAGES') {
|
|
313
|
+
return HttpResponse.json(
|
|
314
|
+
{
|
|
315
|
+
messages: [
|
|
316
|
+
{
|
|
317
|
+
id: 'msg1',
|
|
318
|
+
message: 'Build Started',
|
|
319
|
+
date: '2024-01-15 10:00:00.000000000',
|
|
320
|
+
author: { _account_id: 9999, name: 'CI Bot' },
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
},
|
|
324
|
+
{ headers: { 'Content-Type': 'application/json' } },
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
328
|
+
}),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
const effect = buildStatusCommand('12345', {
|
|
332
|
+
watch: false,
|
|
333
|
+
}).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
|
|
334
|
+
|
|
335
|
+
await Effect.runPromise(effect)
|
|
336
|
+
|
|
337
|
+
// Should only have one output (no polling)
|
|
338
|
+
expect(capturedStdout.length).toBe(1)
|
|
339
|
+
expect(JSON.parse(capturedStdout[0])).toEqual({ state: 'running' })
|
|
340
|
+
|
|
341
|
+
// Should not have watch mode messages in stderr
|
|
342
|
+
expect(capturedErrors.some((e: string) => e.includes('Watching build status'))).toBe(false)
|
|
343
|
+
})
|
|
344
|
+
})
|
|
@@ -1,84 +1,33 @@
|
|
|
1
|
-
import { describe, test, expect, beforeAll, afterAll, afterEach
|
|
2
|
-
import { setupServer } from 'msw/node'
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll, afterEach } from 'bun:test'
|
|
3
2
|
import { http, HttpResponse } from 'msw'
|
|
4
|
-
import { Effect
|
|
3
|
+
import { Effect } from 'effect'
|
|
5
4
|
import { buildStatusCommand } from '@/cli/commands/build-status'
|
|
6
5
|
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
7
|
-
import { ConfigService } from '@/services/config'
|
|
8
6
|
import type { MessageInfo } from '@/schemas/gerrit'
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
_account_id: 1000,
|
|
20
|
-
name: 'Test User',
|
|
21
|
-
email: 'test@example.com',
|
|
22
|
-
})
|
|
23
|
-
}),
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
// Store captured output
|
|
27
|
-
let capturedStdout: string[] = []
|
|
28
|
-
let capturedErrors: string[] = []
|
|
29
|
-
|
|
30
|
-
// Mock process.stdout.write to capture JSON output
|
|
31
|
-
const mockStdoutWrite = mock((chunk: any) => {
|
|
32
|
-
capturedStdout.push(String(chunk))
|
|
33
|
-
return true
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
// Mock console.error to capture errors
|
|
37
|
-
const mockConsoleError = mock((...args: any[]) => {
|
|
38
|
-
capturedErrors.push(args.join(' '))
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
// Mock process.exit to prevent test termination
|
|
42
|
-
const mockProcessExit = mock((_code?: number) => {
|
|
43
|
-
throw new Error('Process exited')
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
// Store original methods
|
|
47
|
-
const originalStdoutWrite = process.stdout.write
|
|
48
|
-
const originalConsoleError = console.error
|
|
49
|
-
const originalProcessExit = process.exit
|
|
7
|
+
import {
|
|
8
|
+
server,
|
|
9
|
+
capturedStdout,
|
|
10
|
+
capturedErrors,
|
|
11
|
+
mockProcessExit,
|
|
12
|
+
setupBuildStatusTests,
|
|
13
|
+
teardownBuildStatusTests,
|
|
14
|
+
resetBuildStatusMocks,
|
|
15
|
+
createMockConfigLayer,
|
|
16
|
+
} from './helpers/build-status-test-setup'
|
|
50
17
|
|
|
51
18
|
beforeAll(() => {
|
|
52
|
-
|
|
53
|
-
// @ts-ignore - Mocking stdout
|
|
54
|
-
process.stdout.write = mockStdoutWrite
|
|
55
|
-
// @ts-ignore - Mocking console
|
|
56
|
-
console.error = mockConsoleError
|
|
57
|
-
// @ts-ignore - Mocking process.exit
|
|
58
|
-
process.exit = mockProcessExit
|
|
19
|
+
setupBuildStatusTests()
|
|
59
20
|
})
|
|
60
21
|
|
|
61
22
|
afterAll(() => {
|
|
62
|
-
|
|
63
|
-
// @ts-ignore - Restoring stdout
|
|
64
|
-
process.stdout.write = originalStdoutWrite
|
|
65
|
-
console.error = originalConsoleError
|
|
66
|
-
// @ts-ignore - Restoring process.exit
|
|
67
|
-
process.exit = originalProcessExit
|
|
23
|
+
teardownBuildStatusTests()
|
|
68
24
|
})
|
|
69
25
|
|
|
70
26
|
afterEach(() => {
|
|
71
|
-
|
|
72
|
-
mockStdoutWrite.mockClear()
|
|
73
|
-
mockConsoleError.mockClear()
|
|
74
|
-
mockProcessExit.mockClear()
|
|
75
|
-
capturedStdout = []
|
|
76
|
-
capturedErrors = []
|
|
27
|
+
resetBuildStatusMocks()
|
|
77
28
|
})
|
|
78
29
|
|
|
79
30
|
describe('build-status command', () => {
|
|
80
|
-
const createMockConfigLayer = () => Layer.succeed(ConfigService, createMockConfigService())
|
|
81
|
-
|
|
82
31
|
test('returns pending when no Build Started message found', async () => {
|
|
83
32
|
const messages: MessageInfo[] = [
|
|
84
33
|
{
|
|
@@ -590,9 +539,9 @@ describe('build-status command', () => {
|
|
|
590
539
|
|
|
591
540
|
try {
|
|
592
541
|
await Effect.runPromise(effect)
|
|
593
|
-
} catch
|
|
594
|
-
// Should throw error and call process.exit
|
|
595
|
-
expect(mockProcessExit).toHaveBeenCalledWith(
|
|
542
|
+
} catch {
|
|
543
|
+
// Should throw error and call process.exit with code 3 for API errors
|
|
544
|
+
expect(mockProcessExit).toHaveBeenCalledWith(3)
|
|
596
545
|
expect(capturedErrors.length).toBeGreaterThan(0)
|
|
597
546
|
}
|
|
598
547
|
})
|
|
@@ -688,4 +637,153 @@ describe('build-status command', () => {
|
|
|
688
637
|
// Regex should handle extra whitespace
|
|
689
638
|
expect(output).toEqual({ state: 'running' })
|
|
690
639
|
})
|
|
640
|
+
|
|
641
|
+
test('ignores verification from older patchset when newer patchset build is running', async () => {
|
|
642
|
+
// This test replicates the bug scenario:
|
|
643
|
+
// - PS 3 build started, then PS 4 build started
|
|
644
|
+
// - PS 3 verification (-1) comes AFTER PS 4 build started
|
|
645
|
+
// - Should return "running" because PS 4 has no verification yet
|
|
646
|
+
const messages: MessageInfo[] = [
|
|
647
|
+
{
|
|
648
|
+
id: 'msg1',
|
|
649
|
+
message: 'Build Started https://jenkins.example.com/job/123/',
|
|
650
|
+
date: '2024-01-15 11:12:00.000000000',
|
|
651
|
+
_revision_number: 2,
|
|
652
|
+
author: {
|
|
653
|
+
_account_id: 9999,
|
|
654
|
+
name: 'Service Cloud Jenkins',
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
id: 'msg2',
|
|
659
|
+
message: 'Patch Set 2: Verified -1\n\nBuild Failed',
|
|
660
|
+
date: '2024-01-15 11:23:00.000000000',
|
|
661
|
+
_revision_number: 2,
|
|
662
|
+
author: {
|
|
663
|
+
_account_id: 9999,
|
|
664
|
+
name: 'Service Cloud Jenkins',
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
id: 'msg3',
|
|
669
|
+
message: 'Build Started https://jenkins.example.com/job/456/',
|
|
670
|
+
date: '2024-01-15 13:57:00.000000000',
|
|
671
|
+
_revision_number: 3,
|
|
672
|
+
author: {
|
|
673
|
+
_account_id: 9999,
|
|
674
|
+
name: 'Service Cloud Jenkins',
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
id: 'msg4',
|
|
679
|
+
message: 'Build Started https://jenkins.example.com/job/789/',
|
|
680
|
+
date: '2024-01-15 14:02:00.000000000',
|
|
681
|
+
_revision_number: 4,
|
|
682
|
+
author: {
|
|
683
|
+
_account_id: 9999,
|
|
684
|
+
name: 'Service Cloud Jenkins',
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
id: 'msg5',
|
|
689
|
+
message: 'Patch Set 3: Verified -1\n\nBuild Failed : ABORTED',
|
|
690
|
+
date: '2024-01-15 14:03:00.000000000',
|
|
691
|
+
_revision_number: 3,
|
|
692
|
+
author: {
|
|
693
|
+
_account_id: 9999,
|
|
694
|
+
name: 'Service Cloud Jenkins',
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
]
|
|
698
|
+
|
|
699
|
+
server.use(
|
|
700
|
+
http.get('*/a/changes/12345', ({ request }) => {
|
|
701
|
+
const url = new URL(request.url)
|
|
702
|
+
if (url.searchParams.get('o') === 'MESSAGES') {
|
|
703
|
+
return HttpResponse.json(
|
|
704
|
+
{ messages },
|
|
705
|
+
{
|
|
706
|
+
headers: { 'Content-Type': 'application/json' },
|
|
707
|
+
},
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
711
|
+
}),
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
const effect = buildStatusCommand('12345').pipe(
|
|
715
|
+
Effect.provide(GerritApiServiceLive),
|
|
716
|
+
Effect.provide(createMockConfigLayer()),
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
await Effect.runPromise(effect)
|
|
720
|
+
|
|
721
|
+
expect(capturedStdout.length).toBe(1)
|
|
722
|
+
const output = JSON.parse(capturedStdout[0])
|
|
723
|
+
// PS 4 build started at 14:02, PS 3 verification at 14:03 should be IGNORED
|
|
724
|
+
// because it's for a different revision. PS 4 build is still running.
|
|
725
|
+
expect(output).toEqual({ state: 'running' })
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
test('returns success when verification matches the latest patchset', async () => {
|
|
729
|
+
const messages: MessageInfo[] = [
|
|
730
|
+
{
|
|
731
|
+
id: 'msg1',
|
|
732
|
+
message: 'Build Started',
|
|
733
|
+
date: '2024-01-15 10:00:00.000000000',
|
|
734
|
+
_revision_number: 1,
|
|
735
|
+
author: {
|
|
736
|
+
_account_id: 9999,
|
|
737
|
+
name: 'CI Bot',
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
id: 'msg2',
|
|
742
|
+
message: 'Build Started',
|
|
743
|
+
date: '2024-01-15 11:00:00.000000000',
|
|
744
|
+
_revision_number: 2,
|
|
745
|
+
author: {
|
|
746
|
+
_account_id: 9999,
|
|
747
|
+
name: 'CI Bot',
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
id: 'msg3',
|
|
752
|
+
message: 'Patch Set 2: Verified+1',
|
|
753
|
+
date: '2024-01-15 11:15:00.000000000',
|
|
754
|
+
_revision_number: 2,
|
|
755
|
+
author: {
|
|
756
|
+
_account_id: 9999,
|
|
757
|
+
name: 'CI Bot',
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
]
|
|
761
|
+
|
|
762
|
+
server.use(
|
|
763
|
+
http.get('*/a/changes/12345', ({ request }) => {
|
|
764
|
+
const url = new URL(request.url)
|
|
765
|
+
if (url.searchParams.get('o') === 'MESSAGES') {
|
|
766
|
+
return HttpResponse.json(
|
|
767
|
+
{ messages },
|
|
768
|
+
{
|
|
769
|
+
headers: { 'Content-Type': 'application/json' },
|
|
770
|
+
},
|
|
771
|
+
)
|
|
772
|
+
}
|
|
773
|
+
return HttpResponse.text('Not Found', { status: 404 })
|
|
774
|
+
}),
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
const effect = buildStatusCommand('12345').pipe(
|
|
778
|
+
Effect.provide(GerritApiServiceLive),
|
|
779
|
+
Effect.provide(createMockConfigLayer()),
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
await Effect.runPromise(effect)
|
|
783
|
+
|
|
784
|
+
expect(capturedStdout.length).toBe(1)
|
|
785
|
+
const output = JSON.parse(capturedStdout[0])
|
|
786
|
+
// PS 2 build started at 11:00, PS 2 verification at 11:15 - same revision, success
|
|
787
|
+
expect(output).toEqual({ state: 'success' })
|
|
788
|
+
})
|
|
691
789
|
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Mock } from 'bun:test'
|
|
2
|
+
import { mock } from 'bun:test'
|
|
3
|
+
import type { SetupServer } from 'msw/node'
|
|
4
|
+
import { setupServer } from 'msw/node'
|
|
5
|
+
import { http, HttpResponse } from 'msw'
|
|
6
|
+
import { Layer } from 'effect'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
import { createMockConfigService } from './config-mock'
|
|
9
|
+
|
|
10
|
+
export const server: SetupServer = setupServer(
|
|
11
|
+
// Default handler for auth check
|
|
12
|
+
http.get('*/a/accounts/self', ({ request }) => {
|
|
13
|
+
const auth = request.headers.get('Authorization')
|
|
14
|
+
if (!auth || !auth.startsWith('Basic ')) {
|
|
15
|
+
return HttpResponse.text('Unauthorized', { status: 401 })
|
|
16
|
+
}
|
|
17
|
+
return HttpResponse.json({
|
|
18
|
+
_account_id: 1000,
|
|
19
|
+
name: 'Test User',
|
|
20
|
+
email: 'test@example.com',
|
|
21
|
+
})
|
|
22
|
+
}),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// Store captured output
|
|
26
|
+
export let capturedStdout: string[] = []
|
|
27
|
+
export let capturedErrors: string[] = []
|
|
28
|
+
|
|
29
|
+
// Mock process.stdout.write to capture JSON output
|
|
30
|
+
export const mockStdoutWrite: Mock<(chunk: unknown) => boolean> = mock(
|
|
31
|
+
(chunk: unknown): boolean => {
|
|
32
|
+
capturedStdout.push(String(chunk))
|
|
33
|
+
return true
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
// Mock console.error to capture errors
|
|
38
|
+
export const mockConsoleError: Mock<(...args: unknown[]) => void> = mock(
|
|
39
|
+
(...args: unknown[]): void => {
|
|
40
|
+
capturedErrors.push(args.join(' '))
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// Mock process.exit to prevent test termination
|
|
45
|
+
export const mockProcessExit: Mock<(code?: number) => never> = mock((_code?: number): never => {
|
|
46
|
+
throw new Error('Process exited')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Store original methods
|
|
50
|
+
export const originalStdoutWrite: typeof process.stdout.write = process.stdout.write
|
|
51
|
+
export const originalConsoleError: typeof console.error = console.error
|
|
52
|
+
export const originalProcessExit: typeof process.exit = process.exit
|
|
53
|
+
|
|
54
|
+
export const setupBuildStatusTests = (): void => {
|
|
55
|
+
server.listen({ onUnhandledRequest: 'bypass' })
|
|
56
|
+
// @ts-ignore - Mocking stdout
|
|
57
|
+
process.stdout.write = mockStdoutWrite
|
|
58
|
+
// @ts-ignore - Mocking console
|
|
59
|
+
console.error = mockConsoleError
|
|
60
|
+
// @ts-ignore - Mocking process.exit
|
|
61
|
+
process.exit = mockProcessExit
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const teardownBuildStatusTests = (): void => {
|
|
65
|
+
server.close()
|
|
66
|
+
// @ts-ignore - Restoring stdout
|
|
67
|
+
process.stdout.write = originalStdoutWrite
|
|
68
|
+
console.error = originalConsoleError
|
|
69
|
+
// @ts-ignore - Restoring process.exit
|
|
70
|
+
process.exit = originalProcessExit
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const resetBuildStatusMocks = (): void => {
|
|
74
|
+
server.resetHandlers()
|
|
75
|
+
mockStdoutWrite.mockClear()
|
|
76
|
+
mockConsoleError.mockClear()
|
|
77
|
+
mockProcessExit.mockClear()
|
|
78
|
+
capturedStdout = []
|
|
79
|
+
capturedErrors = []
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const createMockConfigLayer = (): Layer.Layer<ConfigService> =>
|
|
83
|
+
Layer.succeed(ConfigService, createMockConfigService())
|