@checkstack/healthcheck-backend 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +484 -0
  2. package/drizzle/0019_chemical_frightful_four.sql +8 -0
  3. package/drizzle/0020_certain_mordo.sql +2 -0
  4. package/drizzle/meta/0019_snapshot.json +661 -0
  5. package/drizzle/meta/0020_snapshot.json +711 -0
  6. package/drizzle/meta/_journal.json +14 -0
  7. package/package.json +23 -21
  8. package/src/ai/system-signals-contributor.test.ts +33 -9
  9. package/src/ai/system-signals-contributor.ts +38 -16
  10. package/src/cache-test-stub.ts +26 -0
  11. package/src/cache.test.ts +291 -0
  12. package/src/cache.ts +204 -34
  13. package/src/health-notification-content.test.ts +111 -0
  14. package/src/health-notification-content.ts +145 -0
  15. package/src/healthcheck-gitops-kinds.test.ts +14 -0
  16. package/src/healthcheck-gitops-kinds.ts +27 -0
  17. package/src/index.ts +31 -12
  18. package/src/queue-executor.test.ts +13 -26
  19. package/src/queue-executor.ts +125 -112
  20. package/src/retention-job.ts +8 -0
  21. package/src/rollup-consumer.test.ts +19 -8
  22. package/src/router-config-secrets.test.ts +2 -7
  23. package/src/router-create-and-assign.test.ts +2 -7
  24. package/src/router-pause-recompute.test.ts +2 -7
  25. package/src/router.test.ts +3 -8
  26. package/src/router.ts +43 -15
  27. package/src/schema.ts +74 -31
  28. package/src/service-batching.test.ts +8 -0
  29. package/src/service-bulk-counts.it.test.ts +144 -0
  30. package/src/service-bulk-run-stats.it.test.ts +197 -0
  31. package/src/service-ordering.test.ts +6 -2
  32. package/src/service-paused-filter.test.ts +13 -0
  33. package/src/service-rollup-worst-wins.test.ts +209 -145
  34. package/src/service.ts +408 -284
  35. package/src/status-fingerprint.test.ts +92 -0
  36. package/src/status-fingerprint.ts +66 -0
  37. package/src/status-page/rollup.test.ts +40 -0
  38. package/src/status-page/rollup.ts +27 -0
  39. package/src/status-page/widgets.test.ts +387 -0
  40. package/src/status-page/widgets.ts +236 -39
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ statusFingerprint,
4
+ statusVectorChanged,
5
+ type StatusFingerprintInput,
6
+ } from "./status-fingerprint";
7
+
8
+ const check = (
9
+ configurationId: string,
10
+ status: string,
11
+ sliceCount = 1,
12
+ failingSliceCount = 0,
13
+ ): StatusFingerprintInput["checkStatuses"][number] => ({
14
+ configurationId,
15
+ status,
16
+ sliceCount,
17
+ failingSliceCount,
18
+ });
19
+
20
+ describe("statusFingerprint", () => {
21
+ it("is invariant to check ORDER", () => {
22
+ const a: StatusFingerprintInput = {
23
+ status: "degraded",
24
+ checkStatuses: [check("c1", "healthy"), check("c2", "degraded")],
25
+ };
26
+ const b: StatusFingerprintInput = {
27
+ status: "degraded",
28
+ checkStatuses: [check("c2", "degraded"), check("c1", "healthy")],
29
+ };
30
+ expect(statusFingerprint(a)).toBe(statusFingerprint(b));
31
+ });
32
+
33
+ it("differs when a check's status flips", () => {
34
+ const before = statusFingerprint({
35
+ status: "healthy",
36
+ checkStatuses: [check("c1", "healthy")],
37
+ });
38
+ const after = statusFingerprint({
39
+ status: "healthy",
40
+ checkStatuses: [check("c1", "degraded")],
41
+ });
42
+ expect(before).not.toBe(after);
43
+ });
44
+
45
+ it("differs when only the slice failure count changes", () => {
46
+ const before = statusFingerprint({
47
+ status: "degraded",
48
+ checkStatuses: [check("c1", "degraded", 3, 1)],
49
+ });
50
+ const after = statusFingerprint({
51
+ status: "degraded",
52
+ checkStatuses: [check("c1", "degraded", 3, 2)],
53
+ });
54
+ expect(before).not.toBe(after);
55
+ });
56
+ });
57
+
58
+ describe("statusVectorChanged", () => {
59
+ const base: StatusFingerprintInput = {
60
+ status: "healthy",
61
+ checkStatuses: [check("c1", "healthy"), check("c2", "healthy")],
62
+ };
63
+
64
+ it("is FALSE for a pure timestamp/runs refresh (same vector)", () => {
65
+ // Volatile fields (evaluatedAt / lastRunAt / runsConsidered) are not part of
66
+ // the fingerprint, so an object carrying different ones but the same vector
67
+ // is not a change. Represented here by an identical vector.
68
+ const next: StatusFingerprintInput = {
69
+ status: "healthy",
70
+ checkStatuses: [check("c1", "healthy"), check("c2", "healthy")],
71
+ };
72
+ expect(statusVectorChanged(base, next)).toBe(false);
73
+ });
74
+
75
+ it("is TRUE for a per-check flip even when the rollup enum is unchanged", () => {
76
+ // Rollup stays "healthy" here (contrived), but c2 flipped — the entity view
77
+ // {status, healthyChecks, totalChecks} could miss this, the fingerprint does not.
78
+ const next: StatusFingerprintInput = {
79
+ status: "healthy",
80
+ checkStatuses: [check("c1", "healthy"), check("c2", "degraded")],
81
+ };
82
+ expect(statusVectorChanged(base, next)).toBe(true);
83
+ });
84
+
85
+ it("is TRUE when the check SET changes", () => {
86
+ const next: StatusFingerprintInput = {
87
+ status: "healthy",
88
+ checkStatuses: [check("c1", "healthy")],
89
+ };
90
+ expect(statusVectorChanged(base, next)).toBe(true);
91
+ });
92
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Per-check status fingerprint — the change-signal that gates system-health
3
+ * cache invalidation.
4
+ *
5
+ * The cached value is the full aggregated system-health response
6
+ * (`getSystemHealthStatus`), but most of its fields are VOLATILE: every run
7
+ * bumps `evaluatedAt`, a check's `lastRunAt`, and `runsConsidered` even when
8
+ * nothing an operator sees actually changed. Invalidating on those would defeat
9
+ * the cache (every tick evicts). The fingerprint is invariant to them: it
10
+ * captures ONLY the derived-status vector a reader gates on:
11
+ * - the system-wide rollup `status`, and
12
+ * - per check: `configurationId`, its derived `status`, and its slice
13
+ * composition (`sliceCount` / `failingSliceCount`).
14
+ *
15
+ * So `statusVectorChanged(prev, next)` is true exactly when a check flipped
16
+ * status, a slice began/stopped failing, or the check set changed — i.e. when a
17
+ * status a reader renders actually moved. A run that merely refreshes timestamps
18
+ * with the same vector is NOT a change, so the reconcile skips invalidation and
19
+ * the cached value (correct except for volatile fields no reader needs) survives
20
+ * to its TTL. This is the "broaden the change-signal to the per-check status
21
+ * vector" requirement: it catches a per-check flip that leaves the rollup enum
22
+ * unchanged (which the entity view's `{status, healthyChecks, totalChecks}`
23
+ * would miss), while ignoring pure timestamp churn.
24
+ */
25
+
26
+ /**
27
+ * Structural subset of `SystemHealthStatusResponse` the fingerprint reads. The
28
+ * service's response is assignable to this, so no import (and no cast) is needed
29
+ * — keeping this module a leaf the cache and executor can both depend on.
30
+ */
31
+ export interface StatusFingerprintInput {
32
+ status: string;
33
+ checkStatuses: readonly {
34
+ configurationId: string;
35
+ status: string;
36
+ sliceCount: number;
37
+ failingSliceCount: number;
38
+ }[];
39
+ }
40
+
41
+ /**
42
+ * A stable, order-independent fingerprint of a system-health response's derived
43
+ * status vector. Checks are sorted by `configurationId` so two responses with
44
+ * the same checks in a different order fingerprint identically.
45
+ */
46
+ export function statusFingerprint(response: StatusFingerprintInput): string {
47
+ const perCheck = response.checkStatuses
48
+ .map(
49
+ (c) =>
50
+ `${c.configurationId}:${c.status}:${c.sliceCount}:${c.failingSliceCount}`,
51
+ )
52
+ .toSorted();
53
+ return `${response.status}|${perCheck.join(",")}`;
54
+ }
55
+
56
+ /**
57
+ * Whether the derived status vector changed between two responses (ignoring
58
+ * volatile fields). This is the gate for cache invalidation + cross-pod
59
+ * broadcast: only a real vector change evicts the cache and wakes other pods.
60
+ */
61
+ export function statusVectorChanged(
62
+ previous: StatusFingerprintInput,
63
+ next: StatusFingerprintInput,
64
+ ): boolean {
65
+ return statusFingerprint(previous) !== statusFingerprint(next);
66
+ }
@@ -3,6 +3,7 @@ import {
3
3
  mapHealthStatus,
4
4
  rollupStatus,
5
5
  overallBannerStatus,
6
+ rollupSelectedEnvironments,
6
7
  statusBannerTitle,
7
8
  } from "./rollup";
8
9
 
@@ -45,6 +46,45 @@ describe("overallBannerStatus", () => {
45
46
  });
46
47
  });
47
48
 
49
+ describe("rollupSelectedEnvironments", () => {
50
+ const environments = {
51
+ prod: { status: "healthy" },
52
+ staging: { status: "unhealthy" },
53
+ dev: { status: "degraded" },
54
+ };
55
+ test("considers only the selected environments (worst-wins)", () => {
56
+ expect(
57
+ rollupSelectedEnvironments({
58
+ environments,
59
+ selectedEnvironmentIds: ["prod"],
60
+ }),
61
+ ).toBe("operational");
62
+ expect(
63
+ rollupSelectedEnvironments({
64
+ environments,
65
+ selectedEnvironmentIds: ["prod", "staging"],
66
+ }),
67
+ ).toBe("major_outage");
68
+ expect(
69
+ rollupSelectedEnvironments({
70
+ environments,
71
+ selectedEnvironmentIds: ["prod", "dev"],
72
+ }),
73
+ ).toBe("degraded");
74
+ });
75
+ test("no slice in any selected env -> unknown", () => {
76
+ expect(
77
+ rollupSelectedEnvironments({
78
+ environments,
79
+ selectedEnvironmentIds: ["nonexistent"],
80
+ }),
81
+ ).toBe("unknown");
82
+ expect(
83
+ rollupSelectedEnvironments({ environments, selectedEnvironmentIds: [] }),
84
+ ).toBe("unknown");
85
+ });
86
+ });
87
+
48
88
  describe("statusBannerTitle", () => {
49
89
  test("renders a human title", () => {
50
90
  expect(statusBannerTitle("operational")).toBe("All systems operational");
@@ -58,6 +58,33 @@ export function overallBannerStatus(statuses: PublicStatus[]): PublicStatus {
58
58
  return rollupStatus(known);
59
59
  }
60
60
 
61
+ /**
62
+ * Roll up a system's PER-ENVIRONMENT health-check statuses into a single public
63
+ * status for a status page scoped to specific environments. ONLY the slices for
64
+ * the selected environments are considered (worst-status-wins via the public
65
+ * precedence); a system with no slice in any selected environment resolves to
66
+ * `unknown`. Used by the health widgets when a page publishes an explicit
67
+ * environment set, so a system in both prod and staging shows only its
68
+ * selected-environment health rather than the cross-environment rollup.
69
+ *
70
+ * This considers CHECK statuses only. Incident-forced overrides are whole-system
71
+ * (not environment-scoped), so the caller folds the override IN via worst-wins on
72
+ * top of this result (see `healthPublicStatuses`) - keeping this helper a pure
73
+ * per-environment checks rollup.
74
+ */
75
+ export function rollupSelectedEnvironments(args: {
76
+ environments: Record<string, { status: string }>;
77
+ selectedEnvironmentIds: string[];
78
+ }): PublicStatus {
79
+ const { environments, selectedEnvironmentIds } = args;
80
+ const statuses: PublicStatus[] = [];
81
+ for (const envId of selectedEnvironmentIds) {
82
+ const slice = environments[envId];
83
+ if (slice) statuses.push(mapHealthStatus(slice.status));
84
+ }
85
+ return rollupStatus(statuses);
86
+ }
87
+
61
88
  export function statusBannerTitle(status: PublicStatus): string {
62
89
  switch (status) {
63
90
  case "operational": {
@@ -0,0 +1,387 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { RpcClient } from "@checkstack/backend-api";
3
+ import type {
4
+ WidgetResolveContext,
5
+ WidgetTypeDefinition,
6
+ StatusWidgetTypeExtensionPoint,
7
+ } from "@checkstack/status-page-backend";
8
+ import { registerHealthcheckStatusWidgets } from "./widgets";
9
+
10
+ /**
11
+ * Widget-level regression coverage for status-page environment filtering (the
12
+ * gap that let the standalone uptime widget slip through). Exercises the four
13
+ * health widgets directly against a mocked trusted rpcClient, asserting that a
14
+ * published-environment set:
15
+ * - omits systems outside the selected environments from banner / systemHealth
16
+ * / groupStatus AND blanks the single-system uptime widget, and
17
+ * - folds the WHOLE-SYSTEM incident override into the env-scoped health rollup
18
+ * (a prod system whose prod checks are green but which is under an active
19
+ * incident-forced outage still reads as an outage on a prod-scoped page).
20
+ */
21
+
22
+ /** Per-system per-environment CHECKS status, as getBulkSystemHealthMatrix returns. */
23
+ type MatrixEnvStatus = "healthy" | "degraded" | "unhealthy";
24
+
25
+ interface MockData {
26
+ /** environmentId -> member system ids (catalog env->systems mapping). */
27
+ envSystems: Record<string, string[]>;
28
+ /** systemId -> display name. */
29
+ systemNames: Record<string, string>;
30
+ /** systemId -> per-environment CHECKS status. */
31
+ matrix: Record<string, Record<string, MatrixEnvStatus>>;
32
+ /** systemId -> whole-system incident override status (folded, worst-wins). */
33
+ overrides?: Record<string, MatrixEnvStatus>;
34
+ /** systemId -> total uptime percent (for the uptime widget). */
35
+ uptimePct?: Record<string, number>;
36
+ /** catalog groupId -> member system ids. */
37
+ groups?: Record<string, string[]>;
38
+ }
39
+
40
+ function widgetsById(): Map<string, WidgetTypeDefinition> {
41
+ const map = new Map<string, WidgetTypeDefinition>();
42
+ const ext: StatusWidgetTypeExtensionPoint = {
43
+ registerWidgetType: (def) => map.set(def.id, def),
44
+ };
45
+ registerHealthcheckStatusWidgets(ext);
46
+ return map;
47
+ }
48
+
49
+ function makeCtx(args: {
50
+ data: MockData;
51
+ publishedEnvironmentIds?: string[];
52
+ }): WidgetResolveContext {
53
+ const { data, publishedEnvironmentIds } = args;
54
+ const memo = new Map<string, Promise<unknown>>();
55
+ const api = {
56
+ // catalog
57
+ resolveEnvironments: async ({
58
+ environmentIds,
59
+ }: {
60
+ environmentIds: string[];
61
+ }) =>
62
+ environmentIds.map((id) => ({
63
+ id,
64
+ name: id,
65
+ description: null,
66
+ systemIds: data.envSystems[id] ?? [],
67
+ metadata: null,
68
+ createdAt: new Date(),
69
+ updatedAt: new Date(),
70
+ })),
71
+ getSystems: async () => ({
72
+ systems: Object.entries(data.systemNames).map(([id, name]) => ({
73
+ id,
74
+ name,
75
+ })),
76
+ }),
77
+ getGroups: async () =>
78
+ Object.entries(data.groups ?? {}).map(([id, systemIds]) => ({
79
+ id,
80
+ name: id,
81
+ systemIds,
82
+ })),
83
+ // maintenance
84
+ getBulkMaintenancesForSystems: async () => ({ maintenances: {} }),
85
+ // healthcheck
86
+ getBulkSystemHealthMatrix: async ({ systemIds }: { systemIds: string[] }) => {
87
+ const statuses: Record<
88
+ string,
89
+ {
90
+ status: MatrixEnvStatus;
91
+ checkStatuses: never[];
92
+ environments: Record<
93
+ string,
94
+ { status: MatrixEnvStatus; checkStatuses: never[] }
95
+ >;
96
+ }
97
+ > = {};
98
+ for (const id of systemIds) {
99
+ const envs = data.matrix[id];
100
+ if (!envs) continue;
101
+ statuses[id] = {
102
+ status: "healthy",
103
+ checkStatuses: [],
104
+ environments: Object.fromEntries(
105
+ Object.entries(envs).map(([env, status]) => [
106
+ env,
107
+ { status, checkStatuses: [] },
108
+ ]),
109
+ ),
110
+ };
111
+ }
112
+ return { statuses };
113
+ },
114
+ getBulkSystemHealthStatus: async ({ systemIds }: { systemIds: string[] }) => {
115
+ const statuses: Record<
116
+ string,
117
+ {
118
+ status: MatrixEnvStatus;
119
+ evaluatedAt: Date;
120
+ checkStatuses: never[];
121
+ override?: { status: MatrixEnvStatus; source: string; reason: string };
122
+ }
123
+ > = {};
124
+ for (const id of systemIds) {
125
+ const override = data.overrides?.[id];
126
+ statuses[id] = {
127
+ status: "healthy",
128
+ evaluatedAt: new Date(),
129
+ checkStatuses: [],
130
+ ...(override
131
+ ? { override: { status: override, source: "incident", reason: "x" } }
132
+ : {}),
133
+ };
134
+ }
135
+ return { statuses };
136
+ },
137
+ getRunStats: async ({ systemId }: { systemId: string }) => {
138
+ const pct = data.uptimePct?.[systemId] ?? 100;
139
+ return {
140
+ window: { start: "2026-07-01T00:00:00Z", end: "2026-07-02T00:00:00Z" },
141
+ bucketIntervalSeconds: 86_400,
142
+ total: {
143
+ runCount: 10,
144
+ healthy: 10,
145
+ degraded: 0,
146
+ unhealthy: 0,
147
+ uptimePct: pct,
148
+ },
149
+ buckets: [
150
+ {
151
+ start: "2026-07-01T00:00:00Z",
152
+ end: "2026-07-02T00:00:00Z",
153
+ runCount: 10,
154
+ healthy: 10,
155
+ degraded: 0,
156
+ unhealthy: 0,
157
+ uptimePct: pct,
158
+ },
159
+ ],
160
+ };
161
+ },
162
+ };
163
+ return {
164
+ rpcClient: { forPlugin: () => api } as unknown as RpcClient,
165
+ cache: <T,>(key: string, loader: () => Promise<T>): Promise<T> => {
166
+ const existing = memo.get(key);
167
+ if (existing) return existing as Promise<T>;
168
+ const created = loader();
169
+ memo.set(key, created);
170
+ return created;
171
+ },
172
+ publishedEnvironmentIds,
173
+ };
174
+ }
175
+
176
+ // prod-sys: prod only (checks healthy, but under an active incident override).
177
+ // stage-sys: staging only (checks unhealthy).
178
+ // both-sys: prod + staging (prod healthy, staging unhealthy).
179
+ const DATA: MockData = {
180
+ envSystems: {
181
+ prod: ["prod-sys", "both-sys"],
182
+ stage: ["stage-sys", "both-sys"],
183
+ },
184
+ systemNames: {
185
+ "prod-sys": "Prod System",
186
+ "stage-sys": "Stage System",
187
+ "both-sys": "Both System",
188
+ },
189
+ matrix: {
190
+ "prod-sys": { prod: "healthy" },
191
+ "stage-sys": { stage: "unhealthy" },
192
+ "both-sys": { prod: "healthy", stage: "unhealthy" },
193
+ },
194
+ overrides: { "prod-sys": "unhealthy" }, // whole-system incident-forced outage
195
+ uptimePct: { "prod-sys": 99, "stage-sys": 50, "both-sys": 88 },
196
+ groups: { g1: ["prod-sys", "stage-sys"] },
197
+ };
198
+
199
+ describe("health widgets — environment filtering (E3 regression)", () => {
200
+ const publishedEnvironmentIds = ["prod"];
201
+
202
+ test("systemHealth omits staging-only systems and folds the whole-system override", async () => {
203
+ const widget = widgetsById().get("systemHealth")!;
204
+ const ctx = makeCtx({ data: DATA, publishedEnvironmentIds });
205
+ const result = (await widget.resolvePublic({
206
+ config: {
207
+ items: [
208
+ { systemId: "prod-sys" },
209
+ { systemId: "stage-sys" },
210
+ { systemId: "both-sys" },
211
+ ],
212
+ },
213
+ ctx,
214
+ })) as { systems: Array<{ label: string; status: string }> };
215
+ // stage-sys is dropped; prod-sys shows the incident override (major_outage)
216
+ // even though its prod checks are healthy; both-sys shows its prod checks.
217
+ expect(result.systems).toEqual([
218
+ { label: "Prod System", status: "major_outage" },
219
+ { label: "Both System", status: "operational" },
220
+ ]);
221
+ });
222
+
223
+ test("banner rolls up only env-visible systems, override included", async () => {
224
+ const widget = widgetsById().get("banner")!;
225
+ const ctx = makeCtx({ data: DATA, publishedEnvironmentIds });
226
+ const result = (await widget.resolvePublic({
227
+ config: { systemIds: ["prod-sys", "stage-sys", "both-sys"] },
228
+ ctx,
229
+ })) as { status: string };
230
+ // Visible = {prod-sys (major via override), both-sys (operational)} ->
231
+ // some-but-not-all down = partial_outage. stage-sys never counted.
232
+ expect(result.status).toBe("partial_outage");
233
+ });
234
+
235
+ test("groupStatus drops group members outside the published environments", async () => {
236
+ const widget = widgetsById().get("groupStatus")!;
237
+ const ctx = makeCtx({ data: DATA, publishedEnvironmentIds });
238
+ const result = (await widget.resolvePublic({
239
+ config: { groupId: "g1" },
240
+ ctx,
241
+ })) as { systems: Array<{ label: string; status: string }>; status: string };
242
+ // g1 = [prod-sys, stage-sys]; only prod-sys is in prod. Its override lifts
243
+ // it to major_outage; stage-sys is omitted entirely.
244
+ expect(result.systems).toEqual([
245
+ { label: "Prod System", status: "major_outage" },
246
+ ]);
247
+ expect(result.status).toBe("major_outage");
248
+ });
249
+
250
+ test("uptime widget is blanked for a system outside the published environments", async () => {
251
+ const widget = widgetsById().get("uptime")!;
252
+ const ctx = makeCtx({ data: DATA, publishedEnvironmentIds });
253
+ const result = (await widget.resolvePublic({
254
+ config: { systemId: "stage-sys", days: 30 },
255
+ ctx,
256
+ })) as { label: string; uptimePct: number; bars: unknown[] };
257
+ // Out of scope -> blank empty-state DTO (no bars, no misleading percent).
258
+ expect(result.bars).toEqual([]);
259
+ expect(result.uptimePct).toBe(0);
260
+ });
261
+
262
+ test("uptime widget renders normally for an in-scope system", async () => {
263
+ const widget = widgetsById().get("uptime")!;
264
+ const ctx = makeCtx({ data: DATA, publishedEnvironmentIds });
265
+ const result = (await widget.resolvePublic({
266
+ config: { systemId: "prod-sys", days: 30 },
267
+ ctx,
268
+ })) as { label: string; uptimePct: number; bars: unknown[] };
269
+ expect(result.label).toBe("Prod System");
270
+ expect(result.bars.length).toBe(1);
271
+ expect(result.uptimePct).toBe(99);
272
+ });
273
+ });
274
+
275
+ describe("health widgets — no environment filter (unchanged behavior)", () => {
276
+ test("systemHealth keeps every system and uses the folded cross-env status", async () => {
277
+ const widget = widgetsById().get("systemHealth")!;
278
+ const ctx = makeCtx({ data: DATA }); // no publishedEnvironmentIds
279
+ const result = (await widget.resolvePublic({
280
+ config: {
281
+ items: [{ systemId: "prod-sys" }, { systemId: "stage-sys" }],
282
+ },
283
+ ctx,
284
+ })) as { systems: Array<{ label: string; status: string }> };
285
+ // All-env path reads getBulkSystemHealthStatus.status (healthy in the mock)
286
+ // for both systems; no system is dropped.
287
+ expect(result.systems).toEqual([
288
+ { label: "Prod System", status: "operational" },
289
+ { label: "Stage System", status: "operational" },
290
+ ]);
291
+ });
292
+
293
+ test("uptime widget is never blanked when no environment filter is set", async () => {
294
+ const widget = widgetsById().get("uptime")!;
295
+ const ctx = makeCtx({ data: DATA });
296
+ const result = (await widget.resolvePublic({
297
+ config: { systemId: "stage-sys", days: 30 },
298
+ ctx,
299
+ })) as { bars: unknown[]; uptimePct: number };
300
+ expect(result.bars.length).toBe(1);
301
+ expect(result.uptimePct).toBe(50);
302
+ });
303
+ });
304
+
305
+ /**
306
+ * Send-time SCOPING regression: the subscriber fan-out only surfaces a HEALTH
307
+ * notification through a health widget, and only for the systems the widget
308
+ * CURRENTLY shows (its configured systems ∩ the page's published environments).
309
+ * Each health widget must therefore declare `subscriptionCategory: "health"` and
310
+ * a `resolveScopedSystems` that matches what `resolvePublic` renders.
311
+ */
312
+ describe("health widgets — resolveScopedSystems (send-time scoping)", () => {
313
+ test("every health widget is tagged with the 'health' subscription category", () => {
314
+ const widgets = widgetsById();
315
+ for (const id of ["banner", "systemHealth", "groupStatus", "uptime"]) {
316
+ expect(widgets.get(id)!.subscriptionCategory).toBe("health");
317
+ }
318
+ });
319
+
320
+ const scoped = ["prod"];
321
+
322
+ test("banner scopes its systems to the published environments", async () => {
323
+ const widget = widgetsById().get("banner")!;
324
+ const ctx = makeCtx({ data: DATA, publishedEnvironmentIds: scoped });
325
+ const set = await widget.resolveScopedSystems!({
326
+ config: { systemIds: ["prod-sys", "stage-sys", "both-sys"] },
327
+ ctx,
328
+ });
329
+ expect([...set].toSorted()).toEqual(["both-sys", "prod-sys"]);
330
+ });
331
+
332
+ test("systemHealth scopes to env and applies per-row label overrides in the detailed list", async () => {
333
+ const widget = widgetsById().get("systemHealth")!;
334
+ const ctx = makeCtx({ data: DATA, publishedEnvironmentIds: scoped });
335
+ const config = {
336
+ items: [
337
+ { systemId: "prod-sys", label: "PROD!" },
338
+ { systemId: "stage-sys" },
339
+ { systemId: "both-sys" },
340
+ ],
341
+ };
342
+ const set = await widget.resolveScopedSystems!({ config, ctx });
343
+ expect([...set].toSorted()).toEqual(["both-sys", "prod-sys"]);
344
+ const detailed = await widget.resolveScopedSystemsDetailed!({ config, ctx });
345
+ expect(detailed).toEqual([
346
+ { id: "prod-sys", name: "PROD!" }, // label override wins over catalog name
347
+ { id: "both-sys", name: "Both System" },
348
+ ]);
349
+ });
350
+
351
+ test("groupStatus scopes its expanded members to the published environments", async () => {
352
+ const widget = widgetsById().get("groupStatus")!;
353
+ const ctx = makeCtx({ data: DATA, publishedEnvironmentIds: scoped });
354
+ const set = await widget.resolveScopedSystems!({
355
+ config: { groupId: "g1" },
356
+ ctx,
357
+ });
358
+ expect([...set]).toEqual(["prod-sys"]); // g1 = [prod-sys, stage-sys]; prod only
359
+ });
360
+
361
+ test("uptime scopes to its single system, emptying when it is out of scope", async () => {
362
+ const widget = widgetsById().get("uptime")!;
363
+ const ctx = makeCtx({ data: DATA, publishedEnvironmentIds: scoped });
364
+ expect([
365
+ ...(await widget.resolveScopedSystems!({
366
+ config: { systemId: "prod-sys", days: 30 },
367
+ ctx,
368
+ })),
369
+ ]).toEqual(["prod-sys"]);
370
+ expect([
371
+ ...(await widget.resolveScopedSystems!({
372
+ config: { systemId: "stage-sys", days: 30 },
373
+ ctx,
374
+ })),
375
+ ]).toEqual([]);
376
+ });
377
+
378
+ test("no environment filter surfaces every configured system", async () => {
379
+ const widget = widgetsById().get("banner")!;
380
+ const ctx = makeCtx({ data: DATA }); // all environments
381
+ const set = await widget.resolveScopedSystems!({
382
+ config: { systemIds: ["prod-sys", "stage-sys", "both-sys"] },
383
+ ctx,
384
+ });
385
+ expect([...set].toSorted()).toEqual(["both-sys", "prod-sys", "stage-sys"]);
386
+ });
387
+ });