Skip to content

Commit 14d5b2d

Browse files
danielpaulusclaude
andauthored
fix(constructs): emit numeric gRPC health-check status from healthCheckStatus() (SIM-307) (#1406)
GrpcAssertionBuilder.healthCheckStatus() returned a GeneralAssertionBuilder, so the CLI's own documented example .equals('SERVING') shipped target "SERVING" to the API. The go-runner evaluates gRPC health status numerically (strconv.Atoi), so the label could never pass ("Invalid. Target health status is not a number") — the advertised example built a permanently-failing check. Only the webapp normalized the label to a number. - healthCheckStatus() now returns a dedicated HealthCheckStatusAssertionBuilder exposing only .equals()/.notEquals() (the only comparisons the runner supports), accepting a GrpcHealthStatus label or the raw 0|1|2|3 enum and emitting the numeric wire target. Mirrors the webapp's grpcHealthStatusTargetByLabel mapping (single source of truth). - Codegen reverse-maps the number back to the label so an imported check round-trips to .equals('SERVING'); unmappable/legacy targets fall back to a raw assertion object literal so the generated code always type-checks. - Validation flags a GRPC_HEALTHCHECK_STATUS target that isn't 0-3, catching object-literal callers who bypass the builder. Reviewed via tri-model daniel-review (Codex + Gemini + Claude); all findings fixed. Build clean, 21 gRPC unit/codegen tests green. Claude-Session: https://claude.ai/code/session_013AYwkvoaANQTH5dGStbBj2 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3af93dd commit 14d5b2d

5 files changed

Lines changed: 323 additions & 10 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
import { valueForGrpcAssertion } from '../grpc-assertion-codegen.js'
4+
import { GrpcAssertion } from '../grpc-assertion.js'
5+
import { GeneratedFile, Output } from '../../sourcegen/index.js'
6+
7+
function render (assertion: GrpcAssertion): string {
8+
const output = new Output()
9+
const file = new GeneratedFile('foo.ts')
10+
const result = valueForGrpcAssertion(file, assertion)
11+
result.render(output)
12+
return output.finalize()
13+
}
14+
15+
describe('gRPC Assertion Codegen', () => {
16+
it('round-trips the numeric health-check status target back to its label', () => {
17+
const cases: { input: GrpcAssertion, expected: string }[] = [
18+
{
19+
input: { source: 'GRPC_HEALTHCHECK_STATUS', property: '', comparison: 'EQUALS', target: '0', regex: null },
20+
expected: 'GrpcAssertionBuilder.healthCheckStatus().equals(\'UNKNOWN\')\n',
21+
},
22+
{
23+
input: { source: 'GRPC_HEALTHCHECK_STATUS', property: '', comparison: 'EQUALS', target: '1', regex: null },
24+
expected: 'GrpcAssertionBuilder.healthCheckStatus().equals(\'SERVING\')\n',
25+
},
26+
{
27+
input: { source: 'GRPC_HEALTHCHECK_STATUS', property: '', comparison: 'EQUALS', target: '2', regex: null },
28+
expected: 'GrpcAssertionBuilder.healthCheckStatus().equals(\'NOT_SERVING\')\n',
29+
},
30+
{
31+
input: { source: 'GRPC_HEALTHCHECK_STATUS', property: '', comparison: 'EQUALS', target: '3', regex: null },
32+
expected: 'GrpcAssertionBuilder.healthCheckStatus().equals(\'SERVICE_UNKNOWN\')\n',
33+
},
34+
{
35+
input: { source: 'GRPC_HEALTHCHECK_STATUS', property: '', comparison: 'NOT_EQUALS', target: '2', regex: null },
36+
expected: 'GrpcAssertionBuilder.healthCheckStatus().notEquals(\'NOT_SERVING\')\n',
37+
},
38+
]
39+
for (const test of cases) {
40+
expect(render(test.input)).toEqual(test.expected)
41+
}
42+
})
43+
44+
it('falls back to a raw assertion object literal for an unmappable health-check status target', () => {
45+
// '9' is out of the 0-3 serving-status range and isn't a valid label, so a
46+
// builder call would not type-check against the narrowed `equals`/`notEquals`
47+
// parameter type. The raw `Assertion` shape compiles (target is a plain string)
48+
// and faithfully round-trips the stored value instead.
49+
const input: GrpcAssertion =
50+
{ source: 'GRPC_HEALTHCHECK_STATUS', property: '', comparison: 'EQUALS', target: '9', regex: null }
51+
expect(render(input)).toEqual(
52+
'{\n'
53+
+ ' source: \'GRPC_HEALTHCHECK_STATUS\',\n'
54+
+ ' comparison: \'EQUALS\',\n'
55+
+ ' target: \'9\',\n'
56+
+ ' property: \'\',\n'
57+
+ ' regex: null,\n'
58+
+ '}\n',
59+
)
60+
})
61+
})

packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,58 @@ describe('GrpcMonitor', () => {
107107
expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } })
108108
})
109109

110+
describe('healthCheckStatus assertion builder', () => {
111+
it('maps each label to its numeric serving-status wire target', () => {
112+
const cases: { label: 'UNKNOWN' | 'SERVING' | 'NOT_SERVING' | 'SERVICE_UNKNOWN', target: string }[] = [
113+
{ label: 'UNKNOWN', target: '0' },
114+
{ label: 'SERVING', target: '1' },
115+
{ label: 'NOT_SERVING', target: '2' },
116+
{ label: 'SERVICE_UNKNOWN', target: '3' },
117+
]
118+
for (const { label, target } of cases) {
119+
expect(GrpcAssertionBuilder.healthCheckStatus().equals(label)).toEqual({
120+
source: 'GRPC_HEALTHCHECK_STATUS',
121+
comparison: 'EQUALS',
122+
target,
123+
property: '',
124+
regex: null,
125+
})
126+
}
127+
})
128+
129+
it('emits notEquals with the numeric target', () => {
130+
expect(GrpcAssertionBuilder.healthCheckStatus().notEquals('NOT_SERVING')).toEqual({
131+
source: 'GRPC_HEALTHCHECK_STATUS',
132+
comparison: 'NOT_EQUALS',
133+
target: '2',
134+
property: '',
135+
regex: null,
136+
})
137+
})
138+
139+
it('accepts the raw numeric enum value for power users', () => {
140+
expect(GrpcAssertionBuilder.healthCheckStatus().equals(1)).toEqual({
141+
source: 'GRPC_HEALTHCHECK_STATUS',
142+
comparison: 'EQUALS',
143+
target: '1',
144+
property: '',
145+
regex: null,
146+
})
147+
})
148+
149+
it('accepts the falsy raw numeric enum value 0 (UNKNOWN)', () => {
150+
// Pins the falsy boundary: `0` must not be treated as "no target given" by
151+
// grpcHealthStatusWireTarget's `typeof target === 'number'` branch.
152+
expect(GrpcAssertionBuilder.healthCheckStatus().equals(0)).toEqual({
153+
source: 'GRPC_HEALTHCHECK_STATUS',
154+
comparison: 'EQUALS',
155+
target: '0',
156+
property: '',
157+
regex: null,
158+
})
159+
})
160+
})
161+
110162
describe('validation', () => {
111163
it('should error if degradedResponseTime is above 180000', async () => {
112164
setupProject()
@@ -235,6 +287,67 @@ describe('GrpcMonitor', () => {
235287
expect(diags.isFatal()).toEqual(false)
236288
})
237289

290+
it('should error on a health-check status assertion with a non-numeric target', async () => {
291+
setupProject()
292+
const check = new GrpcMonitor('test-check', {
293+
name: 'Test Check',
294+
request: {
295+
...request,
296+
// Object literal bypasses the builder: a raw label can never pass the
297+
// runner's numeric evaluation.
298+
assertions: [
299+
{ source: 'GRPC_HEALTHCHECK_STATUS', property: '', comparison: 'EQUALS', target: 'SERVING', regex: null },
300+
],
301+
},
302+
})
303+
const diags = new Diagnostics()
304+
await check.validate(diags)
305+
expect(diags.isFatal()).toEqual(true)
306+
expect(diags.observations).toEqual(expect.arrayContaining([
307+
expect.objectContaining({
308+
message: expect.stringContaining('has an invalid health-check status target "SERVING"'),
309+
}),
310+
]))
311+
})
312+
313+
it('should error on a health-check status assertion with an out-of-range numeric target', async () => {
314+
setupProject()
315+
const check = new GrpcMonitor('test-check', {
316+
name: 'Test Check',
317+
request: {
318+
...request,
319+
assertions: [
320+
{ source: 'GRPC_HEALTHCHECK_STATUS', property: '', comparison: 'EQUALS', target: '9', regex: null },
321+
],
322+
},
323+
})
324+
const diags = new Diagnostics()
325+
await check.validate(diags)
326+
expect(diags.isFatal()).toEqual(true)
327+
expect(diags.observations).toEqual(expect.arrayContaining([
328+
expect.objectContaining({
329+
message: expect.stringContaining('has an invalid health-check status target "9"'),
330+
}),
331+
]))
332+
})
333+
334+
it('should not error on a health-check status assertion with a numeric target', async () => {
335+
setupProject()
336+
const check = new GrpcMonitor('test-check', {
337+
name: 'Test Check',
338+
request: {
339+
...request,
340+
assertions: [
341+
{ source: 'GRPC_HEALTHCHECK_STATUS', property: '', comparison: 'EQUALS', target: '1', regex: null },
342+
GrpcAssertionBuilder.healthCheckStatus().equals('SERVING'),
343+
],
344+
},
345+
})
346+
const diags = new Diagnostics()
347+
await check.validate(diags)
348+
expect(diags.isFatal()).toEqual(false)
349+
})
350+
238351
it('should not error on a property carried by any gRPC source', async () => {
239352
setupProject()
240353
const check = new GrpcMonitor('test-check', {

packages/cli/src/constructs/grpc-assertion-codegen.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,69 @@
1-
import { GeneratedFile, Value } from '../sourcegen/index.js'
1+
import { expr, ident, object, GeneratedFile, Value } from '../sourcegen/index.js'
22
import { unsupportedAssertionSource, valueForGeneralAssertion, valueForNumericAssertion } from './internal/assertion-codegen.js'
3-
import { GrpcAssertion } from './grpc-assertion.js'
3+
import { GrpcAssertion, GrpcHealthStatus, grpcHealthStatusTargetByLabel } from './grpc-assertion.js'
4+
5+
// Reverse of grpcHealthStatusTargetByLabel: numeric wire target -> friendly label.
6+
// Derived from the shared map so the label↔number mapping lives in one place.
7+
const grpcHealthStatusLabelByTarget: Record<string, GrpcHealthStatus> = Object.fromEntries(
8+
Object.entries(grpcHealthStatusTargetByLabel).map(([label, target]) => [target, label as GrpcHealthStatus]),
9+
)
10+
11+
function isGrpcHealthStatusLabel (value: string): value is GrpcHealthStatus {
12+
return Object.hasOwn(grpcHealthStatusTargetByLabel, value)
13+
}
14+
15+
// Resolves a stored target to the label the builder's (narrowed) `equals`/`notEquals`
16+
// parameter type accepts — either the label for a known number, or the target itself
17+
// when it is already a valid label (a legacy/hand-written check may store one).
18+
// Returns undefined for anything else (out-of-range number, empty, garbage), since a
19+
// builder call for those would not compile against the narrowed builder type.
20+
function grpcHealthStatusLabelForTarget (target: string): GrpcHealthStatus | undefined {
21+
return grpcHealthStatusLabelByTarget[target] ?? (isGrpcHealthStatusLabel(target) ? target : undefined)
22+
}
23+
24+
// Emits `GrpcAssertionBuilder.healthCheckStatus().equals('SERVING')`, reverse-mapping
25+
// the numeric wire target back to its label. When the stored target maps to neither
26+
// a known number nor a valid label (out-of-range, empty, or otherwise unmappable), a
27+
// builder call would not type-check against the narrowed builder parameter type, so
28+
// the raw `Assertion` object-literal shape is emitted instead — which the `Assertion`
29+
// type accepts as a plain string and faithfully round-trips the stored value.
30+
function valueForGrpcHealthCheckStatusAssertion (assertion: GrpcAssertion): Value {
31+
const label = grpcHealthStatusLabelForTarget(assertion.target)
32+
if (label === undefined) {
33+
return object(builder => {
34+
builder.string('source', assertion.source)
35+
builder.string('comparison', assertion.comparison)
36+
builder.string('target', assertion.target)
37+
builder.string('property', assertion.property)
38+
if (assertion.regex === null) {
39+
builder.null('regex')
40+
} else {
41+
builder.string('regex', assertion.regex)
42+
}
43+
})
44+
}
45+
46+
return expr(ident('GrpcAssertionBuilder'), builder => {
47+
builder.member(ident('healthCheckStatus'))
48+
builder.call(() => {})
49+
switch (assertion.comparison) {
50+
case 'EQUALS':
51+
builder.member(ident('equals'))
52+
builder.call(builder => {
53+
builder.string(label)
54+
})
55+
break
56+
case 'NOT_EQUALS':
57+
builder.member(ident('notEquals'))
58+
builder.call(builder => {
59+
builder.string(label)
60+
})
61+
break
62+
default:
63+
throw new Error(`Unsupported comparison ${assertion.comparison} for assertion source ${assertion.source}`)
64+
}
65+
})
66+
}
467

568
export function valueForGrpcAssertion (genfile: GeneratedFile, assertion: GrpcAssertion): Value {
669
genfile.namedImport('GrpcAssertionBuilder', 'checkly/constructs')
@@ -11,10 +74,7 @@ export function valueForGrpcAssertion (genfile: GeneratedFile, assertion: GrpcAs
1174
case 'GRPC_STATUS_CODE':
1275
return valueForNumericAssertion('GrpcAssertionBuilder', 'statusCode', assertion)
1376
case 'GRPC_HEALTHCHECK_STATUS':
14-
return valueForGeneralAssertion('GrpcAssertionBuilder', 'healthCheckStatus', assertion, {
15-
hasProperty: false,
16-
hasRegex: false,
17-
})
77+
return valueForGrpcHealthCheckStatusAssertion(assertion)
1878
case 'GRPC_RESPONSE':
1979
return valueForGeneralAssertion('GrpcAssertionBuilder', 'responseMessage', assertion, {
2080
hasProperty: true,

packages/cli/src/constructs/grpc-assertion-validation.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { Diagnostics } from './diagnostics.js'
2-
import { GrpcAssertion } from './grpc-assertion.js'
2+
import { GrpcAssertion, grpcHealthStatusTargetByLabel } from './grpc-assertion.js'
33
import { addAssertionDiagnostic, quotedKeys } from './internal/assertion-validation.js'
44

5+
// The gRPC runner evaluates health-check status numerically (it parses `target`
6+
// with `Atoi`), so the only valid wire targets are the numeric serving-status
7+
// values. A label like "SERVING" — or garbage — sent as a raw target can never
8+
// pass. The GrpcAssertionBuilder emits these numbers; object-literal users bypass it.
9+
const grpcHealthStatusTargets: Record<string, true> = Object.fromEntries(
10+
Object.values(grpcHealthStatusTargetByLabel).map(target => [target, true]),
11+
)
12+
513
// Keyed by the source union so a member added to GrpcAssertion['source'] without a
614
// matching entry here is a compile-time error.
715
const assertionSources: Record<GrpcAssertion['source'], true> = {
@@ -62,4 +70,16 @@ export function validateGrpcAssertion (
6270
+ `${assertion.comparison === '' ? '(none)' : `"${assertion.comparison}"`}. `
6371
+ `Expected one of ${quotedKeys(assertionComparisons)}.`)
6472
}
73+
74+
// The runner evaluates health-check status numerically, so a non-numeric target
75+
// (e.g. the label "SERVING", written directly on an object literal) can never
76+
// pass. Use GrpcAssertionBuilder.healthCheckStatus() to emit the numeric value.
77+
if (assertion.source === 'GRPC_HEALTHCHECK_STATUS'
78+
&& !Object.hasOwn(grpcHealthStatusTargets, assertion.target)) {
79+
addAssertionDiagnostic(diagnostics,
80+
`The assertion at "${location}" has an invalid health-check status target `
81+
+ `${assertion.target === '' ? '(none)' : `"${assertion.target}"`}. `
82+
+ `Expected a numeric serving-status value: one of ${quotedKeys(grpcHealthStatusTargets)}. `
83+
+ `Use GrpcAssertionBuilder.healthCheckStatus().equals('SERVING') to emit it from a label.`)
84+
}
6585
}

packages/cli/src/constructs/grpc-assertion.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Assertion as CoreAssertion, NumericAssertionBuilder, GeneralAssertionBuilder } from './internal/assertion.js'
1+
import { Assertion as CoreAssertion, NumericAssertionBuilder, GeneralAssertionBuilder, toAssertion } from './internal/assertion.js'
22

33
type GrpcAssertionSource =
44
| 'RESPONSE_TIME'
@@ -10,6 +10,62 @@ type GrpcAssertionSource =
1010

1111
export type GrpcAssertion = CoreAssertion<GrpcAssertionSource>
1212

13+
/**
14+
* Health-check serving-status labels for use with
15+
* {@link GrpcAssertionBuilder.healthCheckStatus}. These are the labels of the
16+
* `grpc.health.v1.HealthCheckResponse.ServingStatus` enum.
17+
*
18+
* @example
19+
* ```typescript
20+
* GrpcAssertionBuilder.healthCheckStatus().equals('SERVING')
21+
* ```
22+
*/
23+
export type GrpcHealthStatus = 'UNKNOWN' | 'SERVING' | 'NOT_SERVING' | 'SERVICE_UNKNOWN'
24+
25+
/**
26+
* Maps each {@link GrpcHealthStatus} label to the numeric wire target the gRPC
27+
* runner evaluates health-check status against (it parses `target` with `Atoi`).
28+
* These are the `grpc.health.v1.HealthCheckResponse.ServingStatus` enum values:
29+
* 0 = UNKNOWN, 1 = SERVING, 2 = NOT_SERVING, 3 = SERVICE_UNKNOWN.
30+
*
31+
* This is the single source of truth for the label↔number mapping — the codegen
32+
* reverse-maps through it rather than duplicating the literals.
33+
*/
34+
export const grpcHealthStatusTargetByLabel: Record<GrpcHealthStatus, string> = {
35+
UNKNOWN: '0',
36+
SERVING: '1',
37+
NOT_SERVING: '2',
38+
SERVICE_UNKNOWN: '3',
39+
}
40+
41+
// A health-check status target: either a friendly label or, for power users, the
42+
// raw numeric enum value the wire actually carries.
43+
type GrpcHealthStatusTarget = GrpcHealthStatus | 0 | 1 | 2 | 3
44+
45+
function grpcHealthStatusWireTarget (target: GrpcHealthStatusTarget): string {
46+
// Types keep TS callers to a known label or number, but a JS caller can bypass
47+
// them with an invalid string (e.g. lowercase 'serving'). Falling back to the
48+
// raw value — mirroring the webapp's `?? assertion.target` — preserves it so it
49+
// still surfaces (rather than as an empty string) in the validation diagnostic.
50+
return typeof target === 'number' ? target.toString() : grpcHealthStatusTargetByLabel[target] ?? String(target)
51+
}
52+
53+
/**
54+
* Assertion builder for the gRPC health-check serving status. The runner evaluates
55+
* the status numerically, so `.equals()`/`.notEquals()` accept a friendly label
56+
* (or the raw enum number) and emit the numeric wire target. Only EQUALS and
57+
* NOT_EQUALS are supported — the runner rejects other comparisons for this source.
58+
*/
59+
class HealthCheckStatusAssertionBuilder {
60+
equals (target: GrpcHealthStatusTarget): GrpcAssertion {
61+
return toAssertion('GRPC_HEALTHCHECK_STATUS', 'EQUALS', grpcHealthStatusWireTarget(target))
62+
}
63+
64+
notEquals (target: GrpcHealthStatusTarget): GrpcAssertion {
65+
return toAssertion('GRPC_HEALTHCHECK_STATUS', 'NOT_EQUALS', grpcHealthStatusWireTarget(target))
66+
}
67+
}
68+
1369
/**
1470
* Builder class for creating gRPC monitor assertions.
1571
* Provides methods to create assertions for gRPC call responses.
@@ -51,10 +107,13 @@ export class GrpcAssertionBuilder {
51107

52108
/**
53109
* Creates an assertion builder for the gRPC health-check status (HEALTH mode).
54-
* @returns A general assertion builder for the health-check status.
110+
* `.equals()`/`.notEquals()` accept a friendly {@link GrpcHealthStatus} label
111+
* (e.g. `'SERVING'`) — or the raw enum number `0`–`3` — and emit the numeric
112+
* serving-status value the runner evaluates.
113+
* @returns An assertion builder for the health-check status.
55114
*/
56115
static healthCheckStatus () {
57-
return new GeneralAssertionBuilder<GrpcAssertionSource>('GRPC_HEALTHCHECK_STATUS')
116+
return new HealthCheckStatusAssertionBuilder()
58117
}
59118

60119
/**

0 commit comments

Comments
 (0)