@oneuptime/common 14.0.5 → 14.0.6

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 (66) hide show
  1. package/Server/Services/MetricService.ts +1 -1
  2. package/Server/Types/Database/QueryHelper.ts +54 -1
  3. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  4. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  5. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  6. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  7. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  8. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  9. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  10. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  11. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  12. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  13. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  14. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  15. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  16. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  17. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  18. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  19. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  20. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  21. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  22. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  23. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  24. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  25. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  26. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  27. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  28. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  29. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  30. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  31. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  32. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  33. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  34. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  35. package/Types/BaseDatabase/AggregationType.ts +7 -0
  36. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  37. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  38. package/Types/Rum/WebVitals.ts +13 -0
  39. package/UI/Components/Input/Input.tsx +11 -0
  40. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  41. package/build/dist/Server/Services/MetricService.js +1 -1
  42. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  43. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  44. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  45. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  46. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  47. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  48. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  49. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  50. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  51. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  52. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  53. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  54. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  55. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  56. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  57. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  58. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  59. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  60. package/build/dist/Types/Rum/WebVitals.js +12 -0
  61. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  62. package/build/dist/UI/Components/Input/Input.js +1 -1
  63. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  64. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  65. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  66. package/package.json +1 -1
@@ -0,0 +1,482 @@
1
+ import {
2
+ AcknowledgeIncidentTool,
3
+ CreateIncidentTool,
4
+ ResolveIncidentTool,
5
+ } from "../../../../Server/Utils/AI/Toolbox/IncidentWriteTools";
6
+ import {
7
+ ObservabilityTool,
8
+ ToolContext,
9
+ ToolExecutionResult,
10
+ } from "../../../../Server/Utils/AI/Toolbox/ToolTypes";
11
+ import IncidentService from "../../../../Server/Services/IncidentService";
12
+ import IncidentSeverityService from "../../../../Server/Services/IncidentSeverityService";
13
+ import Incident from "../../../../Models/DatabaseModels/Incident";
14
+ import IncidentSeverity from "../../../../Models/DatabaseModels/IncidentSeverity";
15
+ import { AIChatCitationTargetType } from "../../../../Types/AI/AIChatTypes";
16
+ import SortOrder from "../../../../Types/BaseDatabase/SortOrder";
17
+ import ObjectID from "../../../../Types/ObjectID";
18
+ import { afterEach, describe, expect, test } from "@jest/globals";
19
+
20
+ /*
21
+ * create_incident, acknowledge_incident and resolve_incident are the copilot's
22
+ * incident mutations. These tests hold the contract around the write rather
23
+ * than the prose handed to the model: tenancy and the acting user come from
24
+ * ctx (never from a tool argument), severity is resolved from the project's
25
+ * own list, the state tools only act on an incident the caller can already
26
+ * see, and every missing input fails loudly instead of writing.
27
+ */
28
+
29
+ const USER_ID: ObjectID = ObjectID.generate();
30
+ const INCIDENT_ID: ObjectID = ObjectID.generate();
31
+ const SEV1_ID: ObjectID = ObjectID.generate();
32
+ const SEV2_ID: ObjectID = ObjectID.generate();
33
+
34
+ const ctx: ToolContext = {
35
+ projectId: ObjectID.generate(),
36
+ props: { isRoot: true, userId: USER_ID },
37
+ };
38
+
39
+ function buildSeverity(
40
+ id: ObjectID,
41
+ name: string,
42
+ order: number,
43
+ ): IncidentSeverity {
44
+ const severity: IncidentSeverity = new IncidentSeverity();
45
+ severity._id = id.toString();
46
+ severity.name = name;
47
+ severity.order = order;
48
+ return severity;
49
+ }
50
+
51
+ function buildSeverities(): Array<IncidentSeverity> {
52
+ return [buildSeverity(SEV1_ID, "SEV1", 1), buildSeverity(SEV2_ID, "SEV2", 2)];
53
+ }
54
+
55
+ function buildIncident(): Incident {
56
+ const incident: Incident = new Incident();
57
+ incident._id = INCIDENT_ID.toString();
58
+ incident.incidentNumber = 42;
59
+ incident.title = "Checkout down";
60
+ return incident;
61
+ }
62
+
63
+ afterEach(() => {
64
+ jest.restoreAllMocks();
65
+ });
66
+
67
+ describe("create_incident", () => {
68
+ function mockSeverities(
69
+ severities: Array<IncidentSeverity> = buildSeverities(),
70
+ ): jest.SpyInstance {
71
+ return jest
72
+ .spyOn(IncidentSeverityService, "findBy")
73
+ .mockResolvedValue(severities as never);
74
+ }
75
+
76
+ function mockCreate(): jest.SpyInstance {
77
+ return jest
78
+ .spyOn(IncidentService, "create")
79
+ .mockResolvedValue(buildIncident() as never);
80
+ }
81
+
82
+ test("creates the incident pinned to ctx's project and user", async () => {
83
+ mockSeverities();
84
+ const createSpy: jest.SpyInstance = mockCreate();
85
+
86
+ const result: ToolExecutionResult = await CreateIncidentTool.execute(
87
+ {
88
+ title: "Checkout down",
89
+ description: "Card payments are failing for every region.",
90
+ },
91
+ ctx,
92
+ );
93
+
94
+ const callArgs: Record<string, unknown> = createSpy.mock
95
+ .calls[0]?.[0] as Record<string, unknown>;
96
+ const data: Incident = callArgs["data"] as Incident;
97
+ expect(data.projectId).toBe(ctx.projectId);
98
+ expect(data.title).toBe("Checkout down");
99
+ expect(data.description).toBe(
100
+ "Card payments are failing for every region.",
101
+ );
102
+ expect(data.createdByUserId).toBe(USER_ID);
103
+ expect(data.rootCause).toBe(
104
+ "Incident created via the OneUptime AI copilot.",
105
+ );
106
+ // The write runs under the caller's props so model-layer RBAC applies too.
107
+ expect(callArgs["props"]).toBe(ctx.props);
108
+
109
+ expect(result.rowCount).toBe(1);
110
+ expect(result.isTruncated).toBe(false);
111
+ expect(result.dataForLlm).toContain("#42");
112
+ expect(result.dataForLlm).toContain("Checkout down");
113
+ expect(result.citationLabel).toBe("Created incident #42");
114
+ expect(result.citationTarget).toEqual({
115
+ type: AIChatCitationTargetType.IncidentView,
116
+ params: { incidentId: INCIDENT_ID.toString() },
117
+ });
118
+ });
119
+
120
+ test("defaults to the project's first-ordered severity", async () => {
121
+ const findSpy: jest.SpyInstance = mockSeverities();
122
+ const createSpy: jest.SpyInstance = mockCreate();
123
+
124
+ await CreateIncidentTool.execute({ title: "Checkout down" }, ctx);
125
+
126
+ // Asked for in the project's own order, so "first" means first-defined.
127
+ const findArgs: Record<string, unknown> = findSpy.mock
128
+ .calls[0]?.[0] as Record<string, unknown>;
129
+ expect(findArgs["sort"]).toEqual({ order: SortOrder.Ascending });
130
+ expect(findArgs["props"]).toBe(ctx.props);
131
+
132
+ const data: Incident = (
133
+ createSpy.mock.calls[0]?.[0] as Record<string, unknown>
134
+ )["data"] as Incident;
135
+ expect(data.incidentSeverityId?.toString()).toBe(SEV1_ID.toString());
136
+ });
137
+
138
+ test("matches a named severity case-insensitively", async () => {
139
+ mockSeverities();
140
+ const createSpy: jest.SpyInstance = mockCreate();
141
+
142
+ const result: ToolExecutionResult = await CreateIncidentTool.execute(
143
+ { title: "Checkout down", severityName: "sev2" },
144
+ ctx,
145
+ );
146
+
147
+ const data: Incident = (
148
+ createSpy.mock.calls[0]?.[0] as Record<string, unknown>
149
+ )["data"] as Incident;
150
+ expect(data.incidentSeverityId?.toString()).toBe(SEV2_ID.toString());
151
+ expect(result.dataForLlm).toContain("SEV2");
152
+ });
153
+
154
+ test("falls back to the first severity when the name matches nothing", async () => {
155
+ mockSeverities();
156
+ const createSpy: jest.SpyInstance = mockCreate();
157
+
158
+ const result: ToolExecutionResult = await CreateIncidentTool.execute(
159
+ { title: "Checkout down", severityName: "Catastrophic" },
160
+ ctx,
161
+ );
162
+
163
+ const data: Incident = (
164
+ createSpy.mock.calls[0]?.[0] as Record<string, unknown>
165
+ )["data"] as Incident;
166
+ expect(data.incidentSeverityId?.toString()).toBe(SEV1_ID.toString());
167
+ // The incident is still created, and the model is told which one it got.
168
+ expect(result.dataForLlm).toContain("SEV1");
169
+ });
170
+
171
+ test("a project with no severities is a loud BadData error, not a bad write", async () => {
172
+ mockSeverities([]);
173
+ const createSpy: jest.SpyInstance = jest.spyOn(IncidentService, "create");
174
+
175
+ await expect(
176
+ CreateIncidentTool.execute({ title: "Checkout down" }, ctx),
177
+ ).rejects.toThrow("No incident severities are configured");
178
+ expect(createSpy).not.toHaveBeenCalled();
179
+ });
180
+
181
+ test("missing title is a loud BadData error and reads nothing", async () => {
182
+ const findSpy: jest.SpyInstance = jest.spyOn(
183
+ IncidentSeverityService,
184
+ "findBy",
185
+ );
186
+
187
+ await expect(
188
+ CreateIncidentTool.execute({ description: "no title" }, ctx),
189
+ ).rejects.toThrow("title is required");
190
+ expect(findSpy).not.toHaveBeenCalled();
191
+ });
192
+
193
+ test("refuses to write without an authenticated user in context", async () => {
194
+ const anonymousCtx: ToolContext = {
195
+ projectId: ctx.projectId,
196
+ props: { isRoot: true },
197
+ };
198
+ const findSpy: jest.SpyInstance = jest.spyOn(
199
+ IncidentSeverityService,
200
+ "findBy",
201
+ );
202
+
203
+ await expect(
204
+ CreateIncidentTool.execute({ title: "Checkout down" }, anonymousCtx),
205
+ ).rejects.toThrow("No authenticated user");
206
+ expect(findSpy).not.toHaveBeenCalled();
207
+ });
208
+
209
+ test("the acting user comes from ctx.props even when args smuggle a userId", async () => {
210
+ mockSeverities();
211
+ const createSpy: jest.SpyInstance = mockCreate();
212
+
213
+ await CreateIncidentTool.execute(
214
+ {
215
+ title: "Checkout down",
216
+ userId: ObjectID.generate().toString(),
217
+ createdByUserId: ObjectID.generate().toString(),
218
+ projectId: ObjectID.generate().toString(),
219
+ },
220
+ ctx,
221
+ );
222
+
223
+ const data: Incident = (
224
+ createSpy.mock.calls[0]?.[0] as Record<string, unknown>
225
+ )["data"] as Incident;
226
+ expect(data.createdByUserId).toBe(USER_ID);
227
+ expect(data.projectId).toBe(ctx.projectId);
228
+ });
229
+
230
+ test("an empty description is omitted from the card, a long one is elided", async () => {
231
+ mockSeverities();
232
+ mockCreate();
233
+
234
+ const withoutDescription: ToolExecutionResult =
235
+ await CreateIncidentTool.execute({ title: "Checkout down" }, ctx);
236
+ const barelabels: Array<string> = (
237
+ (withoutDescription.widget?.data as Record<string, unknown>)[
238
+ "fields"
239
+ ] as Array<{ label: string }>
240
+ ).map((field: { label: string }) => {
241
+ return field.label;
242
+ });
243
+ expect(barelabels).toEqual(["Number", "Severity"]);
244
+
245
+ const longDescription: string = "x".repeat(250);
246
+ const withDescription: ToolExecutionResult =
247
+ await CreateIncidentTool.execute(
248
+ { title: "Checkout down", description: longDescription },
249
+ ctx,
250
+ );
251
+ const fields: Array<{ label: string; value: string }> = (
252
+ withDescription.widget?.data as Record<string, unknown>
253
+ )["fields"] as Array<{ label: string; value: string }>;
254
+ const descriptionField: { label: string; value: string } | undefined =
255
+ fields.find((field: { label: string }) => {
256
+ return field.label === "Description";
257
+ });
258
+ expect(descriptionField?.value).toBe(`${"x".repeat(200)}…`);
259
+ // The full text still reaches the incident itself; only the card is cut.
260
+ expect(descriptionField?.value.length).toBeLessThan(longDescription.length);
261
+ });
262
+
263
+ test("is a mutation whose permissions derive from the Incident model's create ACL", () => {
264
+ expect(CreateIncidentTool.isMutation).toBe(true);
265
+ expect(CreateIncidentTool.requiredPermissions).toEqual(
266
+ new Incident().getCreatePermissions(),
267
+ );
268
+ expect(CreateIncidentTool.requiredPermissions.length).toBeGreaterThan(0);
269
+ });
270
+
271
+ test("buildActionTitle names the incident, or says Untitled", () => {
272
+ expect(
273
+ CreateIncidentTool.buildActionTitle!({ title: "Checkout down" }),
274
+ ).toBe("Create incident: Checkout down");
275
+ expect(CreateIncidentTool.buildActionTitle!({})).toBe(
276
+ "Create incident: Untitled",
277
+ );
278
+ });
279
+ });
280
+
281
+ type StateCase = {
282
+ label: string;
283
+ tool: ObservabilityTool;
284
+ serviceMethod: "acknowledgeIncident" | "resolveIncident";
285
+ newStateName: string;
286
+ actionTitlePrefix: string;
287
+ };
288
+
289
+ const stateCases: Array<StateCase> = [
290
+ {
291
+ label: "acknowledge_incident",
292
+ tool: AcknowledgeIncidentTool,
293
+ serviceMethod: "acknowledgeIncident",
294
+ newStateName: "Acknowledged",
295
+ actionTitlePrefix: "Acknowledge incident",
296
+ },
297
+ {
298
+ label: "resolve_incident",
299
+ tool: ResolveIncidentTool,
300
+ serviceMethod: "resolveIncident",
301
+ newStateName: "Resolved",
302
+ actionTitlePrefix: "Resolve incident",
303
+ },
304
+ ];
305
+
306
+ describe.each(stateCases)(
307
+ "$label",
308
+ ({ tool, serviceMethod, newStateName, actionTitlePrefix }: StateCase) => {
309
+ test("changes the state as ctx's user and cites the incident", async () => {
310
+ jest
311
+ .spyOn(IncidentService, "findOneById")
312
+ .mockResolvedValue(buildIncident() as never);
313
+ const changeSpy: jest.SpyInstance = jest
314
+ .spyOn(IncidentService, serviceMethod)
315
+ .mockResolvedValue(buildIncident() as never);
316
+
317
+ const result: ToolExecutionResult = await tool.execute(
318
+ { incidentId: INCIDENT_ID.toString() },
319
+ ctx,
320
+ );
321
+
322
+ expect(changeSpy).toHaveBeenCalledTimes(1);
323
+ const [calledIncidentId, calledUserId] = changeSpy.mock.calls[0] as [
324
+ ObjectID,
325
+ ObjectID,
326
+ ];
327
+ expect(calledIncidentId.toString()).toBe(INCIDENT_ID.toString());
328
+ expect(calledUserId).toBe(USER_ID);
329
+
330
+ expect(result.rowCount).toBe(1);
331
+ expect(result.isTruncated).toBe(false);
332
+ expect(result.dataForLlm).toContain("#42");
333
+ expect(result.dataForLlm).toContain("Checkout down");
334
+ expect(result.dataForLlm).toContain(newStateName);
335
+ expect(result.citationLabel).toBe(`${newStateName} incident #42`);
336
+ expect(result.citationTarget).toEqual({
337
+ type: AIChatCitationTargetType.IncidentView,
338
+ params: { incidentId: INCIDENT_ID.toString() },
339
+ });
340
+ });
341
+
342
+ test("reads the incident under the caller's own props, not as root", async () => {
343
+ const findSpy: jest.SpyInstance = jest
344
+ .spyOn(IncidentService, "findOneById")
345
+ .mockResolvedValue(buildIncident() as never);
346
+ jest
347
+ .spyOn(IncidentService, serviceMethod)
348
+ .mockResolvedValue(buildIncident() as never);
349
+
350
+ await tool.execute({ incidentId: INCIDENT_ID.toString() }, ctx);
351
+
352
+ const findArgs: Record<string, unknown> = findSpy.mock
353
+ .calls[0]?.[0] as Record<string, unknown>;
354
+ expect(findArgs["props"]).toBe(ctx.props);
355
+ expect(String(findArgs["id"])).toBe(INCIDENT_ID.toString());
356
+ });
357
+
358
+ test("refuses to write when the incident is not visible to the user", async () => {
359
+ jest
360
+ .spyOn(IncidentService, "findOneById")
361
+ .mockResolvedValue(null as never);
362
+ const changeSpy: jest.SpyInstance = jest.spyOn(
363
+ IncidentService,
364
+ serviceMethod,
365
+ );
366
+
367
+ await expect(
368
+ tool.execute({ incidentId: INCIDENT_ID.toString() }, ctx),
369
+ ).rejects.toThrow("Incident not found");
370
+ expect(changeSpy).not.toHaveBeenCalled();
371
+ });
372
+
373
+ test("missing incidentId is a loud BadData error and reads nothing", async () => {
374
+ const findSpy: jest.SpyInstance = jest.spyOn(
375
+ IncidentService,
376
+ "findOneById",
377
+ );
378
+
379
+ await expect(tool.execute({}, ctx)).rejects.toThrow(
380
+ "incidentId is required",
381
+ );
382
+ expect(findSpy).not.toHaveBeenCalled();
383
+ });
384
+
385
+ test("refuses to write without an authenticated user in context", async () => {
386
+ const anonymousCtx: ToolContext = {
387
+ projectId: ctx.projectId,
388
+ props: { isRoot: true },
389
+ };
390
+ const findSpy: jest.SpyInstance = jest.spyOn(
391
+ IncidentService,
392
+ "findOneById",
393
+ );
394
+
395
+ await expect(
396
+ tool.execute({ incidentId: INCIDENT_ID.toString() }, anonymousCtx),
397
+ ).rejects.toThrow("No authenticated user");
398
+ expect(findSpy).not.toHaveBeenCalled();
399
+ });
400
+
401
+ test("carries a resource card widget that deep-links to the incident", async () => {
402
+ jest
403
+ .spyOn(IncidentService, "findOneById")
404
+ .mockResolvedValue(buildIncident() as never);
405
+ jest
406
+ .spyOn(IncidentService, serviceMethod)
407
+ .mockResolvedValue(buildIncident() as never);
408
+
409
+ const result: ToolExecutionResult = await tool.execute(
410
+ { incidentId: INCIDENT_ID.toString() },
411
+ ctx,
412
+ );
413
+
414
+ const widgetData: Record<string, unknown> = result.widget?.data as Record<
415
+ string,
416
+ unknown
417
+ >;
418
+ expect(widgetData["resourceType"]).toBe("Incident");
419
+ expect(widgetData["heading"]).toBe("#42 · Checkout down");
420
+ expect(widgetData["subheading"]).toBe(`Now ${newStateName}`);
421
+ expect(widgetData["link"]).toEqual({
422
+ type: AIChatCitationTargetType.IncidentView,
423
+ params: { incidentId: INCIDENT_ID.toString() },
424
+ });
425
+ expect(widgetData["fields"]).toEqual([
426
+ { label: "Number", value: "#42" },
427
+ { label: "State", value: newStateName },
428
+ ]);
429
+ });
430
+
431
+ test("buildActionTitle names the incident, and stays tidy with no id", () => {
432
+ expect(
433
+ tool.buildActionTitle!({ incidentId: INCIDENT_ID.toString() }),
434
+ ).toBe(`${actionTitlePrefix} ${INCIDENT_ID.toString()}`);
435
+ expect(tool.buildActionTitle!({})).toBe(actionTitlePrefix);
436
+ });
437
+
438
+ test("is a mutation whose permissions derive from the Incident model's update ACL", () => {
439
+ expect(tool.isMutation).toBe(true);
440
+ expect(tool.requiredPermissions).toEqual(
441
+ new Incident().getUpdatePermissions(),
442
+ );
443
+ expect(tool.requiredPermissions.length).toBeGreaterThan(0);
444
+ });
445
+ },
446
+ );
447
+
448
+ describe("the incident write tools as a set", () => {
449
+ test("each tool has its own name", () => {
450
+ expect(CreateIncidentTool.name).toBe("create_incident");
451
+ expect(AcknowledgeIncidentTool.name).toBe("acknowledge_incident");
452
+ expect(ResolveIncidentTool.name).toBe("resolve_incident");
453
+ });
454
+
455
+ test("resolve does not acknowledge, and acknowledge does not resolve", async () => {
456
+ jest
457
+ .spyOn(IncidentService, "findOneById")
458
+ .mockResolvedValue(buildIncident() as never);
459
+ const acknowledgeSpy: jest.SpyInstance = jest
460
+ .spyOn(IncidentService, "acknowledgeIncident")
461
+ .mockResolvedValue(buildIncident() as never);
462
+ const resolveSpy: jest.SpyInstance = jest
463
+ .spyOn(IncidentService, "resolveIncident")
464
+ .mockResolvedValue(buildIncident() as never);
465
+
466
+ await ResolveIncidentTool.execute(
467
+ { incidentId: INCIDENT_ID.toString() },
468
+ ctx,
469
+ );
470
+
471
+ expect(resolveSpy).toHaveBeenCalledTimes(1);
472
+ expect(acknowledgeSpy).not.toHaveBeenCalled();
473
+
474
+ await AcknowledgeIncidentTool.execute(
475
+ { incidentId: INCIDENT_ID.toString() },
476
+ ctx,
477
+ );
478
+
479
+ expect(acknowledgeSpy).toHaveBeenCalledTimes(1);
480
+ expect(resolveSpy).toHaveBeenCalledTimes(1);
481
+ });
482
+ });
@@ -256,6 +256,60 @@ describe("CompareCriteria", () => {
256
256
  });
257
257
  });
258
258
 
259
+ describe("convertMetricThresholdToNumber", () => {
260
+ test("keeps the fraction of a decimal string (parseInt truncated it)", () => {
261
+ expect(CompareCriteria.convertMetricThresholdToNumber("0.9")).toBe(0.9);
262
+ expect(CompareCriteria.convertMetricThresholdToNumber("85.5")).toBe(85.5);
263
+ expect(CompareCriteria.convertMetricThresholdToNumber("-0.5")).toBe(-0.5);
264
+ expect(CompareCriteria.convertMetricThresholdToNumber(".5")).toBe(0.5);
265
+ expect(CompareCriteria.convertMetricThresholdToNumber("1e-3")).toBe(
266
+ 0.001,
267
+ );
268
+ });
269
+
270
+ test("parses integer strings exactly as convertToNumber does", () => {
271
+ for (const value of ["42", "0", "-7", " 7 ", "10px"]) {
272
+ expect(CompareCriteria.convertMetricThresholdToNumber(value)).toBe(
273
+ CompareCriteria.convertToNumber(value),
274
+ );
275
+ }
276
+ });
277
+
278
+ test("passes finite numbers through unchanged", () => {
279
+ expect(CompareCriteria.convertMetricThresholdToNumber(42)).toBe(42);
280
+ expect(CompareCriteria.convertMetricThresholdToNumber(0)).toBe(0);
281
+ expect(CompareCriteria.convertMetricThresholdToNumber(0.9)).toBe(0.9);
282
+ });
283
+
284
+ test("returns null (not NaN) for a missing or unparseable threshold", () => {
285
+ expect(
286
+ CompareCriteria.convertMetricThresholdToNumber(undefined),
287
+ ).toBeNull();
288
+ expect(CompareCriteria.convertMetricThresholdToNumber("abc")).toBeNull();
289
+ expect(CompareCriteria.convertMetricThresholdToNumber("")).toBeNull();
290
+ expect(CompareCriteria.convertMetricThresholdToNumber(" ")).toBeNull();
291
+ expect(CompareCriteria.convertMetricThresholdToNumber(NaN)).toBeNull();
292
+ });
293
+
294
+ test("returns null for a non-finite threshold", () => {
295
+ /*
296
+ * "> Infinity" can never fire and "< Infinity" always would; neither
297
+ * is a threshold anyone meant.
298
+ */
299
+ expect(
300
+ CompareCriteria.convertMetricThresholdToNumber("Infinity"),
301
+ ).toBeNull();
302
+ expect(
303
+ CompareCriteria.convertMetricThresholdToNumber(Infinity),
304
+ ).toBeNull();
305
+ });
306
+
307
+ test("convertToNumber itself still truncates for non-metric criteria", () => {
308
+ expect(CompareCriteria.convertToNumber("0.9")).toBe(0);
309
+ expect(CompareCriteria.convertToNumber("85.5")).toBe(85);
310
+ });
311
+ });
312
+
259
313
  describe("checkEqualToOrNotEqualTo", () => {
260
314
  test("EqualTo returns a message only when the values match", () => {
261
315
  const filter: CriteriaFilter = makeFilter({