@checkstack/healthcheck-http-backend 0.0.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/CHANGELOG.md +79 -0
- package/package.json +24 -0
- package/src/index.ts +23 -0
- package/src/plugin-metadata.ts +9 -0
- package/src/strategy.test.ts +398 -0
- package/src/strategy.ts +513 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @checkstack/healthcheck-http-backend
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
|
|
8
|
+
- Updated dependencies [d20d274]
|
|
9
|
+
- @checkstack/backend-api@0.0.2
|
|
10
|
+
- @checkstack/common@0.0.2
|
|
11
|
+
- @checkstack/healthcheck-common@0.0.2
|
|
12
|
+
|
|
13
|
+
## 0.0.3
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [b4eb432]
|
|
18
|
+
- Updated dependencies [a65e002]
|
|
19
|
+
- @checkstack/backend-api@1.1.0
|
|
20
|
+
- @checkstack/common@0.2.0
|
|
21
|
+
- @checkstack/healthcheck-common@0.1.1
|
|
22
|
+
|
|
23
|
+
## 0.0.2
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- 81f3f85: ## Breaking: Unified Versioned<T> Architecture
|
|
28
|
+
|
|
29
|
+
Refactored the versioning system to use a unified `Versioned<T>` class instead of separate `VersionedSchema`, `VersionedData`, and `VersionedConfig` types.
|
|
30
|
+
|
|
31
|
+
### Breaking Changes
|
|
32
|
+
|
|
33
|
+
- **`VersionedSchema<T>`** is replaced by `Versioned<T>` class
|
|
34
|
+
- **`VersionedData<T>`** is replaced by `VersionedRecord<T>` interface
|
|
35
|
+
- **`VersionedConfig<T>`** is replaced by `VersionedPluginRecord<T>` interface
|
|
36
|
+
- **`ConfigMigration<F, T>`** is replaced by `Migration<F, T>` interface
|
|
37
|
+
- **`MigrationChain<T>`** is removed (use `Migration<unknown, unknown>[]`)
|
|
38
|
+
- **`migrateVersionedData()`** is removed (use `versioned.parse()`)
|
|
39
|
+
- **`ConfigMigrationRunner`** is removed (migrations are internal to Versioned)
|
|
40
|
+
|
|
41
|
+
### Migration Guide
|
|
42
|
+
|
|
43
|
+
Before:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
const strategy: HealthCheckStrategy = {
|
|
47
|
+
config: {
|
|
48
|
+
version: 1,
|
|
49
|
+
schema: mySchema,
|
|
50
|
+
migrations: [],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const data = await migrateVersionedData(stored, 1, migrations);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
After:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
const strategy: HealthCheckStrategy = {
|
|
60
|
+
config: new Versioned({
|
|
61
|
+
version: 1,
|
|
62
|
+
schema: mySchema,
|
|
63
|
+
migrations: [],
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
const data = await strategy.config.parse(stored);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- Updated dependencies [ffc28f6]
|
|
70
|
+
- Updated dependencies [4dd644d]
|
|
71
|
+
- Updated dependencies [71275dd]
|
|
72
|
+
- Updated dependencies [ae19ff6]
|
|
73
|
+
- Updated dependencies [0babb9c]
|
|
74
|
+
- Updated dependencies [b55fae6]
|
|
75
|
+
- Updated dependencies [b354ab3]
|
|
76
|
+
- Updated dependencies [81f3f85]
|
|
77
|
+
- @checkstack/common@0.1.0
|
|
78
|
+
- @checkstack/backend-api@1.0.0
|
|
79
|
+
- @checkstack/healthcheck-common@0.1.0
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/healthcheck-http-backend",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"lint": "bun run lint:code",
|
|
9
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@checkstack/backend-api": "workspace:*",
|
|
13
|
+
"@checkstack/healthcheck-common": "workspace:*",
|
|
14
|
+
"jsonpath-plus": "^10.3.0",
|
|
15
|
+
"@checkstack/common": "workspace:*"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/bun": "^1.0.0",
|
|
19
|
+
"drizzle-kit": "^0.31.8",
|
|
20
|
+
"typescript": "^5.0.0",
|
|
21
|
+
"@checkstack/tsconfig": "workspace:*",
|
|
22
|
+
"@checkstack/scripts": "workspace:*"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBackendPlugin,
|
|
3
|
+
coreServices,
|
|
4
|
+
} from "@checkstack/backend-api";
|
|
5
|
+
import { HttpHealthCheckStrategy } from "./strategy";
|
|
6
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
7
|
+
|
|
8
|
+
export default createBackendPlugin({
|
|
9
|
+
metadata: pluginMetadata,
|
|
10
|
+
register(env) {
|
|
11
|
+
env.registerInit({
|
|
12
|
+
deps: {
|
|
13
|
+
healthCheckRegistry: coreServices.healthCheckRegistry,
|
|
14
|
+
logger: coreServices.logger,
|
|
15
|
+
},
|
|
16
|
+
init: async ({ healthCheckRegistry, logger }) => {
|
|
17
|
+
logger.debug("🔌 Registering HTTP Health Check Strategy...");
|
|
18
|
+
const strategy = new HttpHealthCheckStrategy();
|
|
19
|
+
healthCheckRegistry.register(strategy);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { definePluginMetadata } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plugin metadata for the HTTP Health Check backend.
|
|
5
|
+
* This is the single source of truth for the plugin ID.
|
|
6
|
+
*/
|
|
7
|
+
export const pluginMetadata = definePluginMetadata({
|
|
8
|
+
pluginId: "healthcheck-http",
|
|
9
|
+
});
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { describe, expect, it, spyOn, afterEach } from "bun:test";
|
|
2
|
+
import { HttpHealthCheckStrategy, HttpHealthCheckConfig } from "./strategy";
|
|
3
|
+
|
|
4
|
+
describe("HttpHealthCheckStrategy", () => {
|
|
5
|
+
const strategy = new HttpHealthCheckStrategy();
|
|
6
|
+
const defaultConfig: HttpHealthCheckConfig = {
|
|
7
|
+
url: "https://example.com/api",
|
|
8
|
+
method: "GET",
|
|
9
|
+
timeout: 5000,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
spyOn(globalThis, "fetch").mockRestore();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("basic execution", () => {
|
|
17
|
+
it("should return healthy for successful response without assertions", async () => {
|
|
18
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
19
|
+
new Response(null, { status: 200 })
|
|
20
|
+
);
|
|
21
|
+
const result = await strategy.execute(defaultConfig);
|
|
22
|
+
expect(result.status).toBe("healthy");
|
|
23
|
+
expect(result.metadata?.statusCode).toBe(200);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should return healthy for any status without status assertion", async () => {
|
|
27
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
28
|
+
new Response(null, { status: 404 })
|
|
29
|
+
);
|
|
30
|
+
const result = await strategy.execute(defaultConfig);
|
|
31
|
+
// Without assertions, any response is "healthy" if reachable
|
|
32
|
+
expect(result.status).toBe("healthy");
|
|
33
|
+
expect(result.metadata?.statusCode).toBe(404);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("statusCode assertions", () => {
|
|
38
|
+
it("should pass statusCode equals assertion", async () => {
|
|
39
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
40
|
+
new Response(null, { status: 200 })
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const config: HttpHealthCheckConfig = {
|
|
44
|
+
...defaultConfig,
|
|
45
|
+
assertions: [{ field: "statusCode", operator: "equals", value: 200 }],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const result = await strategy.execute(config);
|
|
49
|
+
expect(result.status).toBe("healthy");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should fail statusCode equals assertion when mismatch", async () => {
|
|
53
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
54
|
+
new Response(null, { status: 404 })
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const config: HttpHealthCheckConfig = {
|
|
58
|
+
...defaultConfig,
|
|
59
|
+
assertions: [{ field: "statusCode", operator: "equals", value: 200 }],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const result = await strategy.execute(config);
|
|
63
|
+
expect(result.status).toBe("unhealthy");
|
|
64
|
+
expect(result.message).toContain("statusCode");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should pass statusCode lessThan assertion", async () => {
|
|
68
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
69
|
+
new Response(null, { status: 201 })
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const config: HttpHealthCheckConfig = {
|
|
73
|
+
...defaultConfig,
|
|
74
|
+
assertions: [{ field: "statusCode", operator: "lessThan", value: 300 }],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const result = await strategy.execute(config);
|
|
78
|
+
expect(result.status).toBe("healthy");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("responseTime assertions", () => {
|
|
83
|
+
it("should pass responseTime assertion when fast", async () => {
|
|
84
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
85
|
+
new Response(null, { status: 200 })
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const config: HttpHealthCheckConfig = {
|
|
89
|
+
...defaultConfig,
|
|
90
|
+
assertions: [
|
|
91
|
+
{ field: "responseTime", operator: "lessThan", value: 10000 },
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const result = await strategy.execute(config);
|
|
96
|
+
expect(result.status).toBe("healthy");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("contentType assertions", () => {
|
|
101
|
+
it("should pass contentType contains assertion", async () => {
|
|
102
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
103
|
+
new Response(JSON.stringify({}), {
|
|
104
|
+
status: 200,
|
|
105
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const config: HttpHealthCheckConfig = {
|
|
110
|
+
...defaultConfig,
|
|
111
|
+
assertions: [
|
|
112
|
+
{ field: "contentType", operator: "contains", value: "json" },
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const result = await strategy.execute(config);
|
|
117
|
+
expect(result.status).toBe("healthy");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("header assertions", () => {
|
|
122
|
+
it("should pass header exists assertion", async () => {
|
|
123
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
124
|
+
new Response(null, {
|
|
125
|
+
status: 200,
|
|
126
|
+
headers: { "X-Request-Id": "abc123" },
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const config: HttpHealthCheckConfig = {
|
|
131
|
+
...defaultConfig,
|
|
132
|
+
assertions: [
|
|
133
|
+
{ field: "header", headerName: "X-Request-Id", operator: "exists" },
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const result = await strategy.execute(config);
|
|
138
|
+
expect(result.status).toBe("healthy");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("should fail header exists assertion when missing", async () => {
|
|
142
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
143
|
+
new Response(null, { status: 200 })
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const config: HttpHealthCheckConfig = {
|
|
147
|
+
...defaultConfig,
|
|
148
|
+
assertions: [
|
|
149
|
+
{ field: "header", headerName: "X-Missing", operator: "exists" },
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const result = await strategy.execute(config);
|
|
154
|
+
expect(result.status).toBe("unhealthy");
|
|
155
|
+
expect(result.message).toContain("X-Missing");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("should pass header equals assertion", async () => {
|
|
159
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
160
|
+
new Response(null, {
|
|
161
|
+
status: 200,
|
|
162
|
+
headers: { "Cache-Control": "no-cache" },
|
|
163
|
+
})
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const config: HttpHealthCheckConfig = {
|
|
167
|
+
...defaultConfig,
|
|
168
|
+
assertions: [
|
|
169
|
+
{
|
|
170
|
+
field: "header",
|
|
171
|
+
headerName: "Cache-Control",
|
|
172
|
+
operator: "equals",
|
|
173
|
+
value: "no-cache",
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const result = await strategy.execute(config);
|
|
179
|
+
expect(result.status).toBe("healthy");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("jsonPath assertions", () => {
|
|
184
|
+
it("should pass jsonPath equals assertion", async () => {
|
|
185
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
186
|
+
new Response(JSON.stringify({ status: "UP" }), {
|
|
187
|
+
status: 200,
|
|
188
|
+
headers: { "Content-Type": "application/json" },
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const config: HttpHealthCheckConfig = {
|
|
193
|
+
...defaultConfig,
|
|
194
|
+
assertions: [
|
|
195
|
+
{
|
|
196
|
+
field: "jsonPath",
|
|
197
|
+
path: "$.status",
|
|
198
|
+
operator: "equals",
|
|
199
|
+
value: "UP",
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const result = await strategy.execute(config);
|
|
205
|
+
expect(result.status).toBe("healthy");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should fail jsonPath equals assertion", async () => {
|
|
209
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
210
|
+
new Response(JSON.stringify({ status: "DOWN" }), {
|
|
211
|
+
status: 200,
|
|
212
|
+
headers: { "Content-Type": "application/json" },
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const config: HttpHealthCheckConfig = {
|
|
217
|
+
...defaultConfig,
|
|
218
|
+
assertions: [
|
|
219
|
+
{
|
|
220
|
+
field: "jsonPath",
|
|
221
|
+
path: "$.status",
|
|
222
|
+
operator: "equals",
|
|
223
|
+
value: "UP",
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const result = await strategy.execute(config);
|
|
229
|
+
expect(result.status).toBe("unhealthy");
|
|
230
|
+
expect(result.message).toContain("Actual");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("should pass jsonPath exists assertion", async () => {
|
|
234
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
235
|
+
new Response(JSON.stringify({ version: "1.0.0" }), {
|
|
236
|
+
status: 200,
|
|
237
|
+
headers: { "Content-Type": "application/json" },
|
|
238
|
+
})
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const config: HttpHealthCheckConfig = {
|
|
242
|
+
...defaultConfig,
|
|
243
|
+
assertions: [
|
|
244
|
+
{ field: "jsonPath", path: "$.version", operator: "exists" },
|
|
245
|
+
],
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const result = await strategy.execute(config);
|
|
249
|
+
expect(result.status).toBe("healthy");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("should fail jsonPath exists assertion when path not found", async () => {
|
|
253
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
254
|
+
new Response(JSON.stringify({ other: "data" }), {
|
|
255
|
+
status: 200,
|
|
256
|
+
headers: { "Content-Type": "application/json" },
|
|
257
|
+
})
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const config: HttpHealthCheckConfig = {
|
|
261
|
+
...defaultConfig,
|
|
262
|
+
assertions: [
|
|
263
|
+
{ field: "jsonPath", path: "$.missing", operator: "exists" },
|
|
264
|
+
],
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const result = await strategy.execute(config);
|
|
268
|
+
expect(result.status).toBe("unhealthy");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("should pass jsonPath contains assertion", async () => {
|
|
272
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
273
|
+
new Response(JSON.stringify({ message: "Hello World" }), {
|
|
274
|
+
status: 200,
|
|
275
|
+
headers: { "Content-Type": "application/json" },
|
|
276
|
+
})
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const config: HttpHealthCheckConfig = {
|
|
280
|
+
...defaultConfig,
|
|
281
|
+
assertions: [
|
|
282
|
+
{
|
|
283
|
+
field: "jsonPath",
|
|
284
|
+
path: "$.message",
|
|
285
|
+
operator: "contains",
|
|
286
|
+
value: "Hello",
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const result = await strategy.execute(config);
|
|
292
|
+
expect(result.status).toBe("healthy");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("should pass jsonPath matches (regex) assertion", async () => {
|
|
296
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
297
|
+
new Response(JSON.stringify({ id: "abc-123" }), {
|
|
298
|
+
status: 200,
|
|
299
|
+
headers: { "Content-Type": "application/json" },
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const config: HttpHealthCheckConfig = {
|
|
304
|
+
...defaultConfig,
|
|
305
|
+
assertions: [
|
|
306
|
+
{
|
|
307
|
+
field: "jsonPath",
|
|
308
|
+
path: "$.id",
|
|
309
|
+
operator: "matches",
|
|
310
|
+
value: "^[a-z]{3}-\\d{3}$",
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const result = await strategy.execute(config);
|
|
316
|
+
expect(result.status).toBe("healthy");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("should fail when response is not JSON but jsonPath assertions exist", async () => {
|
|
320
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
321
|
+
new Response("Not JSON", {
|
|
322
|
+
status: 200,
|
|
323
|
+
headers: { "Content-Type": "text/plain" },
|
|
324
|
+
})
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const config: HttpHealthCheckConfig = {
|
|
328
|
+
...defaultConfig,
|
|
329
|
+
assertions: [{ field: "jsonPath", path: "$.id", operator: "exists" }],
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const result = await strategy.execute(config);
|
|
333
|
+
expect(result.status).toBe("unhealthy");
|
|
334
|
+
expect(result.message).toContain("not valid JSON");
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe("combined assertions", () => {
|
|
339
|
+
it("should pass multiple assertion types", async () => {
|
|
340
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
341
|
+
new Response(JSON.stringify({ healthy: true }), {
|
|
342
|
+
status: 200,
|
|
343
|
+
headers: {
|
|
344
|
+
"Content-Type": "application/json",
|
|
345
|
+
"X-Request-Id": "test-123",
|
|
346
|
+
},
|
|
347
|
+
})
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const config: HttpHealthCheckConfig = {
|
|
351
|
+
...defaultConfig,
|
|
352
|
+
assertions: [
|
|
353
|
+
{ field: "statusCode", operator: "equals", value: 200 },
|
|
354
|
+
{ field: "responseTime", operator: "lessThan", value: 10000 },
|
|
355
|
+
{ field: "contentType", operator: "contains", value: "json" },
|
|
356
|
+
{ field: "header", headerName: "X-Request-Id", operator: "exists" },
|
|
357
|
+
{
|
|
358
|
+
field: "jsonPath",
|
|
359
|
+
path: "$.healthy",
|
|
360
|
+
operator: "equals",
|
|
361
|
+
value: "true",
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const result = await strategy.execute(config);
|
|
367
|
+
expect(result.status).toBe("healthy");
|
|
368
|
+
expect(result.message).toContain("5 assertion");
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe("custom request options", () => {
|
|
373
|
+
it("should send custom headers with request", async () => {
|
|
374
|
+
let capturedHeaders: Record<string, string> | undefined;
|
|
375
|
+
spyOn(globalThis, "fetch").mockImplementation((async (
|
|
376
|
+
_url: RequestInfo | URL,
|
|
377
|
+
options?: RequestInit
|
|
378
|
+
) => {
|
|
379
|
+
capturedHeaders = options?.headers as Record<string, string>;
|
|
380
|
+
return new Response(null, { status: 200 });
|
|
381
|
+
}) as unknown as typeof fetch);
|
|
382
|
+
|
|
383
|
+
const config: HttpHealthCheckConfig = {
|
|
384
|
+
...defaultConfig,
|
|
385
|
+
headers: [
|
|
386
|
+
{ name: "Authorization", value: "Bearer my-token" },
|
|
387
|
+
{ name: "X-Custom-Header", value: "custom-value" },
|
|
388
|
+
],
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const result = await strategy.execute(config);
|
|
392
|
+
expect(result.status).toBe("healthy");
|
|
393
|
+
expect(capturedHeaders).toBeDefined();
|
|
394
|
+
expect(capturedHeaders?.["Authorization"]).toBe("Bearer my-token");
|
|
395
|
+
expect(capturedHeaders?.["X-Custom-Header"]).toBe("custom-value");
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
});
|
package/src/strategy.ts
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { JSONPath } from "jsonpath-plus";
|
|
2
|
+
import {
|
|
3
|
+
HealthCheckStrategy,
|
|
4
|
+
HealthCheckResult,
|
|
5
|
+
HealthCheckRunForAggregation,
|
|
6
|
+
Versioned,
|
|
7
|
+
z,
|
|
8
|
+
numericField,
|
|
9
|
+
timeThresholdField,
|
|
10
|
+
stringField,
|
|
11
|
+
evaluateAssertions,
|
|
12
|
+
} from "@checkstack/backend-api";
|
|
13
|
+
import {
|
|
14
|
+
healthResultNumber,
|
|
15
|
+
healthResultString,
|
|
16
|
+
} from "@checkstack/healthcheck-common";
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// SCHEMAS
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Header configuration for custom HTTP headers.
|
|
24
|
+
*/
|
|
25
|
+
export const httpHeaderSchema = z.object({
|
|
26
|
+
name: z.string().min(1).describe("Header name"),
|
|
27
|
+
value: z.string().describe("Header value"),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* JSONPath assertion for response body validation.
|
|
32
|
+
* Supports dynamic operators with runtime type coercion.
|
|
33
|
+
*/
|
|
34
|
+
const jsonPathAssertionSchema = z.object({
|
|
35
|
+
field: z.literal("jsonPath"),
|
|
36
|
+
path: z
|
|
37
|
+
.string()
|
|
38
|
+
.describe("JSONPath expression (e.g. $.status, $.data[0].id)"),
|
|
39
|
+
operator: z.enum([
|
|
40
|
+
"equals",
|
|
41
|
+
"notEquals",
|
|
42
|
+
"contains",
|
|
43
|
+
"startsWith",
|
|
44
|
+
"endsWith",
|
|
45
|
+
"matches",
|
|
46
|
+
"exists",
|
|
47
|
+
"notExists",
|
|
48
|
+
"lessThan",
|
|
49
|
+
"lessThanOrEqual",
|
|
50
|
+
"greaterThan",
|
|
51
|
+
"greaterThanOrEqual",
|
|
52
|
+
]),
|
|
53
|
+
value: z
|
|
54
|
+
.string()
|
|
55
|
+
.optional()
|
|
56
|
+
.describe("Expected value (not needed for exists/notExists)"),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Response header assertion schema.
|
|
61
|
+
* Check for specific header values in the response.
|
|
62
|
+
*/
|
|
63
|
+
const headerAssertionSchema = z.object({
|
|
64
|
+
field: z.literal("header"),
|
|
65
|
+
headerName: z.string().describe("Response header name to check"),
|
|
66
|
+
operator: z.enum([
|
|
67
|
+
"equals",
|
|
68
|
+
"notEquals",
|
|
69
|
+
"contains",
|
|
70
|
+
"startsWith",
|
|
71
|
+
"endsWith",
|
|
72
|
+
"exists",
|
|
73
|
+
]),
|
|
74
|
+
value: z.string().optional().describe("Expected header value"),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* HTTP health check assertion schema using discriminated union.
|
|
79
|
+
*
|
|
80
|
+
* Assertions validate the result of a check:
|
|
81
|
+
* - statusCode: Validate HTTP status code
|
|
82
|
+
* - responseTime: Validate response latency
|
|
83
|
+
* - contentType: Validate Content-Type header
|
|
84
|
+
* - header: Validate any response header
|
|
85
|
+
* - jsonPath: Validate JSON response body content
|
|
86
|
+
*/
|
|
87
|
+
const httpAssertionSchema = z.discriminatedUnion("field", [
|
|
88
|
+
numericField("statusCode", { min: 100, max: 599 }),
|
|
89
|
+
timeThresholdField("responseTime"),
|
|
90
|
+
stringField("contentType"),
|
|
91
|
+
headerAssertionSchema,
|
|
92
|
+
jsonPathAssertionSchema,
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
export type HttpAssertion = z.infer<typeof httpAssertionSchema>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* HTTP health check configuration schema.
|
|
99
|
+
*
|
|
100
|
+
* Config defines HOW to run the check (connection details, request setup).
|
|
101
|
+
* Assertions define WHAT to validate in the result.
|
|
102
|
+
*/
|
|
103
|
+
export const httpHealthCheckConfigSchema = z.object({
|
|
104
|
+
url: z.string().url().describe("The full URL of the endpoint to check."),
|
|
105
|
+
method: z
|
|
106
|
+
.enum(["GET", "POST", "PUT", "DELETE", "HEAD"])
|
|
107
|
+
.default("GET")
|
|
108
|
+
.describe("The HTTP method to use for the request."),
|
|
109
|
+
headers: z
|
|
110
|
+
.array(httpHeaderSchema)
|
|
111
|
+
.optional()
|
|
112
|
+
.describe("Custom HTTP headers to send with the request."),
|
|
113
|
+
timeout: z
|
|
114
|
+
.number()
|
|
115
|
+
.min(100)
|
|
116
|
+
.default(5000)
|
|
117
|
+
.describe("Maximum time in milliseconds to wait for a response."),
|
|
118
|
+
body: z
|
|
119
|
+
.string()
|
|
120
|
+
.optional()
|
|
121
|
+
.describe(
|
|
122
|
+
"Optional request payload body (e.g. JSON for POST requests). [textarea]"
|
|
123
|
+
),
|
|
124
|
+
assertions: z
|
|
125
|
+
.array(httpAssertionSchema)
|
|
126
|
+
.optional()
|
|
127
|
+
.describe("Validation conditions for the response."),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export type HttpHealthCheckConfig = z.infer<typeof httpHealthCheckConfigSchema>;
|
|
131
|
+
|
|
132
|
+
/** Per-run result metadata */
|
|
133
|
+
const httpResultMetadataSchema = z.object({
|
|
134
|
+
statusCode: healthResultNumber({
|
|
135
|
+
"x-chart-type": "counter",
|
|
136
|
+
"x-chart-label": "Status Code",
|
|
137
|
+
}).optional(),
|
|
138
|
+
contentType: healthResultString({
|
|
139
|
+
"x-chart-type": "text",
|
|
140
|
+
"x-chart-label": "Content Type",
|
|
141
|
+
}).optional(),
|
|
142
|
+
failedAssertion: httpAssertionSchema.optional(),
|
|
143
|
+
error: healthResultString({
|
|
144
|
+
"x-chart-type": "status",
|
|
145
|
+
"x-chart-label": "Error",
|
|
146
|
+
}).optional(),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
export type HttpResultMetadata = z.infer<typeof httpResultMetadataSchema>;
|
|
150
|
+
|
|
151
|
+
/** Aggregated metadata for buckets */
|
|
152
|
+
const httpAggregatedMetadataSchema = z.object({
|
|
153
|
+
statusCodeCounts: z.record(z.string(), z.number()).meta({
|
|
154
|
+
"x-chart-type": "bar",
|
|
155
|
+
"x-chart-label": "Status Code Distribution",
|
|
156
|
+
}),
|
|
157
|
+
errorCount: healthResultNumber({
|
|
158
|
+
"x-chart-type": "counter",
|
|
159
|
+
"x-chart-label": "Errors",
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export type HttpAggregatedMetadata = z.infer<
|
|
164
|
+
typeof httpAggregatedMetadataSchema
|
|
165
|
+
>;
|
|
166
|
+
|
|
167
|
+
export class HttpHealthCheckStrategy
|
|
168
|
+
implements
|
|
169
|
+
HealthCheckStrategy<
|
|
170
|
+
HttpHealthCheckConfig,
|
|
171
|
+
HttpResultMetadata,
|
|
172
|
+
HttpAggregatedMetadata
|
|
173
|
+
>
|
|
174
|
+
{
|
|
175
|
+
id = "http";
|
|
176
|
+
displayName = "HTTP/HTTPS Health Check";
|
|
177
|
+
description = "HTTP endpoint health monitoring with flexible assertions";
|
|
178
|
+
|
|
179
|
+
config: Versioned<HttpHealthCheckConfig> = new Versioned({
|
|
180
|
+
version: 2, // Bumped for breaking change
|
|
181
|
+
schema: httpHealthCheckConfigSchema,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
result: Versioned<HttpResultMetadata> = new Versioned({
|
|
185
|
+
version: 2,
|
|
186
|
+
schema: httpResultMetadataSchema,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
aggregatedResult: Versioned<HttpAggregatedMetadata> = new Versioned({
|
|
190
|
+
version: 1,
|
|
191
|
+
schema: httpAggregatedMetadataSchema,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
aggregateResult(
|
|
195
|
+
runs: HealthCheckRunForAggregation<HttpResultMetadata>[]
|
|
196
|
+
): HttpAggregatedMetadata {
|
|
197
|
+
const statusCodeCounts: Record<string, number> = {};
|
|
198
|
+
let errorCount = 0;
|
|
199
|
+
|
|
200
|
+
for (const run of runs) {
|
|
201
|
+
if (run.metadata?.error) {
|
|
202
|
+
errorCount++;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (run.metadata?.statusCode !== undefined) {
|
|
206
|
+
const key = String(run.metadata.statusCode);
|
|
207
|
+
statusCodeCounts[key] = (statusCodeCounts[key] || 0) + 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { statusCodeCounts, errorCount };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async execute(
|
|
215
|
+
config: HttpHealthCheckConfig
|
|
216
|
+
): Promise<HealthCheckResult<HttpResultMetadata>> {
|
|
217
|
+
// Validate and apply defaults from schema
|
|
218
|
+
const validatedConfig = this.config.validate(config);
|
|
219
|
+
|
|
220
|
+
const start = performance.now();
|
|
221
|
+
try {
|
|
222
|
+
// Convert headers array to Record for fetch API
|
|
223
|
+
const headersRecord: Record<string, string> = {};
|
|
224
|
+
if (validatedConfig.headers) {
|
|
225
|
+
for (const header of validatedConfig.headers) {
|
|
226
|
+
headersRecord[header.name] = header.value;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const response = await fetch(validatedConfig.url, {
|
|
231
|
+
method: validatedConfig.method,
|
|
232
|
+
headers: headersRecord,
|
|
233
|
+
body: validatedConfig.body,
|
|
234
|
+
signal: AbortSignal.timeout(validatedConfig.timeout),
|
|
235
|
+
});
|
|
236
|
+
const end = performance.now();
|
|
237
|
+
const latencyMs = Math.round(end - start);
|
|
238
|
+
|
|
239
|
+
// Collect response data for assertions
|
|
240
|
+
const statusCode = response.status;
|
|
241
|
+
const contentType = response.headers.get("content-type") || "";
|
|
242
|
+
|
|
243
|
+
// Collect response headers for header assertions
|
|
244
|
+
// Note: We get headers directly in the assertion loop, not pre-collected
|
|
245
|
+
|
|
246
|
+
// Build values object for standard assertions
|
|
247
|
+
const assertionValues: Record<string, unknown> = {
|
|
248
|
+
statusCode,
|
|
249
|
+
responseTime: latencyMs,
|
|
250
|
+
contentType,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Separate assertions by type
|
|
254
|
+
const standardAssertions: Array<{
|
|
255
|
+
field: string;
|
|
256
|
+
operator: string;
|
|
257
|
+
value?: unknown;
|
|
258
|
+
}> = [];
|
|
259
|
+
const headerAssertions: Array<z.infer<typeof headerAssertionSchema>> = [];
|
|
260
|
+
const jsonPathAssertions: Array<z.infer<typeof jsonPathAssertionSchema>> =
|
|
261
|
+
[];
|
|
262
|
+
|
|
263
|
+
for (const assertion of validatedConfig.assertions || []) {
|
|
264
|
+
if (assertion.field === "header") {
|
|
265
|
+
headerAssertions.push(
|
|
266
|
+
assertion as z.infer<typeof headerAssertionSchema>
|
|
267
|
+
);
|
|
268
|
+
} else if (assertion.field === "jsonPath") {
|
|
269
|
+
jsonPathAssertions.push(
|
|
270
|
+
assertion as z.infer<typeof jsonPathAssertionSchema>
|
|
271
|
+
);
|
|
272
|
+
} else {
|
|
273
|
+
standardAssertions.push(assertion);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Evaluate standard assertions (statusCode, responseTime, contentType)
|
|
278
|
+
const failedStandard = evaluateAssertions(
|
|
279
|
+
standardAssertions,
|
|
280
|
+
assertionValues
|
|
281
|
+
);
|
|
282
|
+
if (failedStandard) {
|
|
283
|
+
return {
|
|
284
|
+
status: "unhealthy",
|
|
285
|
+
latencyMs,
|
|
286
|
+
message: `Assertion failed: ${failedStandard.field} ${
|
|
287
|
+
failedStandard.operator
|
|
288
|
+
} ${"value" in failedStandard ? failedStandard.value : ""}`,
|
|
289
|
+
metadata: {
|
|
290
|
+
statusCode,
|
|
291
|
+
contentType,
|
|
292
|
+
failedAssertion: failedStandard as HttpAssertion,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Evaluate header assertions
|
|
298
|
+
for (const headerAssertion of headerAssertions) {
|
|
299
|
+
// Get header value directly from the response
|
|
300
|
+
const headerValue =
|
|
301
|
+
response.headers.get(headerAssertion.headerName) ?? undefined;
|
|
302
|
+
const passed = this.evaluateHeaderAssertion(
|
|
303
|
+
headerAssertion.operator,
|
|
304
|
+
headerValue,
|
|
305
|
+
headerAssertion.value
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
if (!passed) {
|
|
309
|
+
return {
|
|
310
|
+
status: "unhealthy",
|
|
311
|
+
latencyMs,
|
|
312
|
+
message: `Header assertion failed: ${headerAssertion.headerName} ${
|
|
313
|
+
headerAssertion.operator
|
|
314
|
+
} ${headerAssertion.value || ""}. Actual: ${
|
|
315
|
+
headerValue ?? "(missing)"
|
|
316
|
+
}`,
|
|
317
|
+
metadata: {
|
|
318
|
+
statusCode,
|
|
319
|
+
contentType,
|
|
320
|
+
failedAssertion: headerAssertion,
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Evaluate JSONPath assertions (only if present)
|
|
327
|
+
if (jsonPathAssertions.length > 0) {
|
|
328
|
+
let responseData: unknown;
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
if (contentType.includes("application/json")) {
|
|
332
|
+
responseData = await response.json();
|
|
333
|
+
} else {
|
|
334
|
+
const text = await response.text();
|
|
335
|
+
try {
|
|
336
|
+
responseData = JSON.parse(text);
|
|
337
|
+
} catch {
|
|
338
|
+
return {
|
|
339
|
+
status: "unhealthy",
|
|
340
|
+
latencyMs,
|
|
341
|
+
message:
|
|
342
|
+
"Response is not valid JSON, but JSONPath assertions are configured",
|
|
343
|
+
metadata: { statusCode, contentType },
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
} catch (error_: unknown) {
|
|
348
|
+
return {
|
|
349
|
+
status: "unhealthy",
|
|
350
|
+
latencyMs,
|
|
351
|
+
message: `Failed to parse response body: ${
|
|
352
|
+
(error_ as Error).message
|
|
353
|
+
}`,
|
|
354
|
+
metadata: { statusCode },
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const extractPath = (path: string, json: unknown) =>
|
|
359
|
+
JSONPath({ path, json: json as object, wrap: false });
|
|
360
|
+
|
|
361
|
+
for (const jsonPathAssertion of jsonPathAssertions) {
|
|
362
|
+
const actualValue = extractPath(jsonPathAssertion.path, responseData);
|
|
363
|
+
const passed = this.evaluateJsonPathAssertion(
|
|
364
|
+
jsonPathAssertion.operator,
|
|
365
|
+
actualValue,
|
|
366
|
+
jsonPathAssertion.value
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
if (!passed) {
|
|
370
|
+
return {
|
|
371
|
+
status: "unhealthy",
|
|
372
|
+
latencyMs,
|
|
373
|
+
message: `JSONPath assertion failed: [${
|
|
374
|
+
jsonPathAssertion.path
|
|
375
|
+
}] ${jsonPathAssertion.operator} ${
|
|
376
|
+
jsonPathAssertion.value || ""
|
|
377
|
+
}. Actual: ${JSON.stringify(actualValue)}`,
|
|
378
|
+
metadata: {
|
|
379
|
+
statusCode,
|
|
380
|
+
contentType,
|
|
381
|
+
failedAssertion: jsonPathAssertion,
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const assertionCount = validatedConfig.assertions?.length || 0;
|
|
389
|
+
return {
|
|
390
|
+
status: "healthy",
|
|
391
|
+
latencyMs,
|
|
392
|
+
message: `HTTP ${statusCode}${
|
|
393
|
+
assertionCount > 0 ? ` - passed ${assertionCount} assertion(s)` : ""
|
|
394
|
+
}`,
|
|
395
|
+
metadata: { statusCode, contentType },
|
|
396
|
+
};
|
|
397
|
+
} catch (error: unknown) {
|
|
398
|
+
const end = performance.now();
|
|
399
|
+
const isError = error instanceof Error;
|
|
400
|
+
return {
|
|
401
|
+
status: "unhealthy",
|
|
402
|
+
latencyMs: Math.round(end - start),
|
|
403
|
+
message: isError ? error.message : "Request failed",
|
|
404
|
+
metadata: { error: isError ? error.name : "UnknownError" },
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private evaluateHeaderAssertion(
|
|
410
|
+
operator: string,
|
|
411
|
+
actual: string | undefined,
|
|
412
|
+
expected = ""
|
|
413
|
+
): boolean {
|
|
414
|
+
if (operator === "exists") return actual !== undefined;
|
|
415
|
+
|
|
416
|
+
if (actual === undefined) return false;
|
|
417
|
+
|
|
418
|
+
switch (operator) {
|
|
419
|
+
case "equals": {
|
|
420
|
+
return actual === expected;
|
|
421
|
+
}
|
|
422
|
+
case "notEquals": {
|
|
423
|
+
return actual !== expected;
|
|
424
|
+
}
|
|
425
|
+
case "contains": {
|
|
426
|
+
return actual.includes(expected);
|
|
427
|
+
}
|
|
428
|
+
case "startsWith": {
|
|
429
|
+
return actual.startsWith(expected);
|
|
430
|
+
}
|
|
431
|
+
case "endsWith": {
|
|
432
|
+
return actual.endsWith(expected);
|
|
433
|
+
}
|
|
434
|
+
default: {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private evaluateJsonPathAssertion(
|
|
441
|
+
operator: string,
|
|
442
|
+
actual: unknown,
|
|
443
|
+
expected: string | undefined
|
|
444
|
+
): boolean {
|
|
445
|
+
// Existence checks
|
|
446
|
+
|
|
447
|
+
if (operator === "exists") return actual !== undefined && actual !== null;
|
|
448
|
+
|
|
449
|
+
if (operator === "notExists")
|
|
450
|
+
return actual === undefined || actual === null;
|
|
451
|
+
|
|
452
|
+
// Numeric operators
|
|
453
|
+
if (
|
|
454
|
+
[
|
|
455
|
+
"lessThan",
|
|
456
|
+
"lessThanOrEqual",
|
|
457
|
+
"greaterThan",
|
|
458
|
+
"greaterThanOrEqual",
|
|
459
|
+
].includes(operator)
|
|
460
|
+
) {
|
|
461
|
+
const numActual = Number(actual);
|
|
462
|
+
const numExpected = Number(expected);
|
|
463
|
+
if (Number.isNaN(numActual) || Number.isNaN(numExpected)) return false;
|
|
464
|
+
|
|
465
|
+
switch (operator) {
|
|
466
|
+
case "lessThan": {
|
|
467
|
+
return numActual < numExpected;
|
|
468
|
+
}
|
|
469
|
+
case "lessThanOrEqual": {
|
|
470
|
+
return numActual <= numExpected;
|
|
471
|
+
}
|
|
472
|
+
case "greaterThan": {
|
|
473
|
+
return numActual > numExpected;
|
|
474
|
+
}
|
|
475
|
+
case "greaterThanOrEqual": {
|
|
476
|
+
return numActual >= numExpected;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// String operators
|
|
482
|
+
const strActual = String(actual ?? "");
|
|
483
|
+
const strExpected = expected || "";
|
|
484
|
+
|
|
485
|
+
switch (operator) {
|
|
486
|
+
case "equals": {
|
|
487
|
+
return actual === expected || strActual === strExpected;
|
|
488
|
+
}
|
|
489
|
+
case "notEquals": {
|
|
490
|
+
return actual !== expected && strActual !== strExpected;
|
|
491
|
+
}
|
|
492
|
+
case "contains": {
|
|
493
|
+
return strActual.includes(strExpected);
|
|
494
|
+
}
|
|
495
|
+
case "startsWith": {
|
|
496
|
+
return strActual.startsWith(strExpected);
|
|
497
|
+
}
|
|
498
|
+
case "endsWith": {
|
|
499
|
+
return strActual.endsWith(strExpected);
|
|
500
|
+
}
|
|
501
|
+
case "matches": {
|
|
502
|
+
try {
|
|
503
|
+
return new RegExp(strExpected).test(strActual);
|
|
504
|
+
} catch {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
default: {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|