@oneuptime/common 12.0.0 → 12.0.1

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 (33) hide show
  1. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +12 -2
  2. package/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.ts +12 -2
  3. package/Server/Services/LlmProviderService.ts +69 -9
  4. package/Server/Services/RunnerService.ts +1 -7
  5. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +12 -0
  6. package/Tests/App/Dashboard/RunnerInstallInstructions.test.tsx +119 -0
  7. package/Tests/App/Dashboard/RunnerStatus.test.tsx +274 -0
  8. package/Tests/Server/Services/LlmProviderUsableByProject.test.ts +293 -0
  9. package/Tests/Server/Utils/AI/ToolArgsExtractors.test.ts +141 -0
  10. package/Tests/Server/Utils/AI/Toolbox/WidgetBuilder.test.ts +205 -0
  11. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +897 -0
  12. package/Tests/Types/Runner/RunnerLiveStatus.test.ts +320 -0
  13. package/Tests/UI/Components/AiInvestigationSettingsCard.test.tsx +285 -0
  14. package/Tests/UI/Components/Detail/EntityFields.test.tsx +166 -0
  15. package/Types/Runbook/RunbookStep.ts +15 -0
  16. package/Types/Runner/RunnerLiveStatus.ts +114 -0
  17. package/UI/Components/Detail/Detail.tsx +155 -0
  18. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +16 -4
  19. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  20. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js +11 -1
  21. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js.map +1 -1
  22. package/build/dist/Server/Services/LlmProviderService.js +61 -8
  23. package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
  24. package/build/dist/Server/Services/RunnerService.js +1 -6
  25. package/build/dist/Server/Services/RunnerService.js.map +1 -1
  26. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +11 -0
  27. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  28. package/build/dist/Types/Runbook/RunbookStep.js.map +1 -1
  29. package/build/dist/Types/Runner/RunnerLiveStatus.js +69 -0
  30. package/build/dist/Types/Runner/RunnerLiveStatus.js.map +1 -0
  31. package/build/dist/UI/Components/Detail/Detail.js +97 -0
  32. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  33. package/package.json +1 -1
@@ -0,0 +1,320 @@
1
+ import { describe, expect, test } from "@jest/globals";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import OneUptimeDate from "../../../Types/Date";
5
+ import RunnerLiveStatus, {
6
+ RUNNER_ALIVE_WINDOW_IN_MINUTES,
7
+ getRunnerLiveStatus,
8
+ getRunnerLiveStatusLabel,
9
+ } from "../../../Types/Runner/RunnerLiveStatus";
10
+
11
+ /*
12
+ * getRunnerLiveStatus is the whole reason the Runner UI stopped rendering the
13
+ * connectionStatus column. That column is written exactly twice in a Runner's
14
+ * life — Disconnected by RunnerService.onBeforeCreate, Connected by the first
15
+ * heartbeat — and nothing ever writes it again, so it reports every Runner
16
+ * that has ever connected as connected forever, and reports a Runner that was
17
+ * created five seconds ago and never started as disconnected. Both readings
18
+ * are wrong in the direction that costs an operator the most.
19
+ *
20
+ * These tests pin the replacement, and — the load-bearing half — pin that the
21
+ * dashboard and the server are still deciding "is this Runner alive" from the
22
+ * same constant.
23
+ */
24
+
25
+ type MinutesAgoFunction = (minutes: number) => Date;
26
+
27
+ const minutesAgo: MinutesAgoFunction = (minutes: number): Date => {
28
+ return OneUptimeDate.getSomeMinutesAgo(minutes);
29
+ };
30
+
31
+ describe("getRunnerLiveStatus", () => {
32
+ describe("never connected", () => {
33
+ test("undefined lastAlive is NeverConnected", () => {
34
+ expect(getRunnerLiveStatus(undefined)).toBe(
35
+ RunnerLiveStatus.NeverConnected,
36
+ );
37
+ });
38
+
39
+ test("null lastAlive is NeverConnected", () => {
40
+ expect(getRunnerLiveStatus(null)).toBe(RunnerLiveStatus.NeverConnected);
41
+ });
42
+
43
+ /*
44
+ * A Runner selected without the lastAlive column arrives with the field
45
+ * absent, which is the same shape as never having heartbeated. That is
46
+ * why both call sites pass lastAlive through selectMoreFields.
47
+ */
48
+ test("empty string lastAlive is NeverConnected", () => {
49
+ expect(getRunnerLiveStatus("")).toBe(RunnerLiveStatus.NeverConnected);
50
+ });
51
+ });
52
+
53
+ describe("connected", () => {
54
+ test("a heartbeat right now is Connected", () => {
55
+ expect(getRunnerLiveStatus(OneUptimeDate.getCurrentDate())).toBe(
56
+ RunnerLiveStatus.Connected,
57
+ );
58
+ });
59
+
60
+ test("a heartbeat one minute ago is Connected", () => {
61
+ expect(getRunnerLiveStatus(minutesAgo(1))).toBe(
62
+ RunnerLiveStatus.Connected,
63
+ );
64
+ });
65
+
66
+ /*
67
+ * The Runner heartbeats every 60s, so anything inside the window is a
68
+ * Runner that is polling for work right now.
69
+ */
70
+ test("a heartbeat just inside the window is Connected", () => {
71
+ expect(
72
+ getRunnerLiveStatus(minutesAgo(RUNNER_ALIVE_WINDOW_IN_MINUTES - 1)),
73
+ ).toBe(RunnerLiveStatus.Connected);
74
+ });
75
+
76
+ /*
77
+ * Clock skew between the Runner's host and the server can put lastAlive
78
+ * slightly in the future. That is a live Runner, not a dead one.
79
+ */
80
+ test("a lastAlive in the future is Connected", () => {
81
+ const tenMinutesFromNow: Date = new Date(Date.now() + 10 * 60 * 1000);
82
+
83
+ expect(getRunnerLiveStatus(tenMinutesFromNow)).toBe(
84
+ RunnerLiveStatus.Connected,
85
+ );
86
+ });
87
+ });
88
+
89
+ describe("disconnected", () => {
90
+ test("a heartbeat past the window is Disconnected", () => {
91
+ expect(
92
+ getRunnerLiveStatus(minutesAgo(RUNNER_ALIVE_WINDOW_IN_MINUTES + 1)),
93
+ ).toBe(RunnerLiveStatus.Disconnected);
94
+ });
95
+
96
+ test("a heartbeat an hour ago is Disconnected", () => {
97
+ expect(getRunnerLiveStatus(minutesAgo(60))).toBe(
98
+ RunnerLiveStatus.Disconnected,
99
+ );
100
+ });
101
+
102
+ test("a heartbeat a year ago is Disconnected", () => {
103
+ expect(getRunnerLiveStatus(minutesAgo(60 * 24 * 365))).toBe(
104
+ RunnerLiveStatus.Disconnected,
105
+ );
106
+ });
107
+
108
+ /*
109
+ * The boundary itself. isAfter compares at second granularity, so a
110
+ * timestamp exactly one window old is NOT after the window start and
111
+ * falls out of Connected. Pinned because the alternative — treating the
112
+ * boundary as alive — would disagree with the server, whose query is a
113
+ * strict greaterThan against the same instant.
114
+ */
115
+ test("a heartbeat exactly one window old is Disconnected", () => {
116
+ expect(
117
+ getRunnerLiveStatus(minutesAgo(RUNNER_ALIVE_WINDOW_IN_MINUTES)),
118
+ ).toBe(RunnerLiveStatus.Disconnected);
119
+ });
120
+ });
121
+
122
+ describe("input shapes", () => {
123
+ test("accepts a Date", () => {
124
+ expect(getRunnerLiveStatus(new Date())).toBe(RunnerLiveStatus.Connected);
125
+ });
126
+
127
+ /*
128
+ * The dashboard reads Runners out of a JSON API response, where dates
129
+ * are ISO strings and not Date objects.
130
+ */
131
+ test("accepts an ISO string", () => {
132
+ expect(getRunnerLiveStatus(new Date().toISOString())).toBe(
133
+ RunnerLiveStatus.Connected,
134
+ );
135
+ });
136
+
137
+ test("accepts a stale ISO string", () => {
138
+ expect(getRunnerLiveStatus(minutesAgo(120).toISOString())).toBe(
139
+ RunnerLiveStatus.Disconnected,
140
+ );
141
+ });
142
+
143
+ /*
144
+ * A value we cannot parse is not evidence of life. Reporting Connected
145
+ * here would paint a green dot on a Runner nobody has heard from.
146
+ */
147
+ test("an unparseable string is Disconnected, never Connected", () => {
148
+ expect(getRunnerLiveStatus("not-a-date")).toBe(
149
+ RunnerLiveStatus.Disconnected,
150
+ );
151
+ });
152
+
153
+ test("an Invalid Date is Disconnected, never Connected", () => {
154
+ expect(getRunnerLiveStatus(new Date("nonsense"))).toBe(
155
+ RunnerLiveStatus.Disconnected,
156
+ );
157
+ });
158
+ });
159
+
160
+ describe("the enum", () => {
161
+ test("has exactly three states", () => {
162
+ expect(Object.keys(RunnerLiveStatus).sort()).toEqual([
163
+ "Connected",
164
+ "Disconnected",
165
+ "NeverConnected",
166
+ ]);
167
+ });
168
+
169
+ /*
170
+ * NeverConnected must not collide with the persisted
171
+ * RunnerConnectionStatus wire values ("connected" / "disconnected"), so
172
+ * that nothing can round-trip one into the other by accident.
173
+ */
174
+ test("NeverConnected has its own distinct value", () => {
175
+ expect(RunnerLiveStatus.NeverConnected).toBe("never-connected");
176
+ expect(RunnerLiveStatus.NeverConnected).not.toBe(
177
+ RunnerLiveStatus.Disconnected,
178
+ );
179
+ expect(RunnerLiveStatus.NeverConnected).not.toBe(
180
+ RunnerLiveStatus.Connected,
181
+ );
182
+ });
183
+ });
184
+ });
185
+
186
+ /*
187
+ * The status-to-wording mapping. Both the status bubble and the runbook step
188
+ * editor's Runner picker label through this, so an inversion here would show
189
+ * a live Runner as never connected in two different places at once.
190
+ *
191
+ * Asserted branch by branch rather than by grepping for the three literals:
192
+ * a "the file contains all three words" check passes just as happily with the
193
+ * mapping inverted, which is exactly what an earlier version of this suite did.
194
+ */
195
+ describe("getRunnerLiveStatusLabel", () => {
196
+ test("Connected maps to 'Connected'", () => {
197
+ expect(getRunnerLiveStatusLabel(RunnerLiveStatus.Connected)).toBe(
198
+ "Connected",
199
+ );
200
+ });
201
+
202
+ test("Disconnected maps to 'Disconnected'", () => {
203
+ expect(getRunnerLiveStatusLabel(RunnerLiveStatus.Disconnected)).toBe(
204
+ "Disconnected",
205
+ );
206
+ });
207
+
208
+ test("NeverConnected maps to 'Never connected'", () => {
209
+ expect(getRunnerLiveStatusLabel(RunnerLiveStatus.NeverConnected)).toBe(
210
+ "Never connected",
211
+ );
212
+ });
213
+
214
+ test("the three labels are distinct", () => {
215
+ const labels: Array<string> = [
216
+ RunnerLiveStatus.Connected,
217
+ RunnerLiveStatus.Disconnected,
218
+ RunnerLiveStatus.NeverConnected,
219
+ ].map((status: RunnerLiveStatus): string => {
220
+ return getRunnerLiveStatusLabel(status);
221
+ });
222
+
223
+ expect(new Set(labels).size).toBe(3);
224
+ });
225
+
226
+ /*
227
+ * "Connected" and "Disconnected" are pre-existing i18next keys, translated
228
+ * in all sixteen locale files for the probe pages. Changing the casing or
229
+ * wording here would silently drop fifteen translations back to English.
230
+ */
231
+ test("it reuses the exact pre-existing locale keys", () => {
232
+ expect(getRunnerLiveStatusLabel(RunnerLiveStatus.Connected)).not.toBe(
233
+ "connected",
234
+ );
235
+ expect(getRunnerLiveStatusLabel(RunnerLiveStatus.Disconnected)).not.toBe(
236
+ "disconnected",
237
+ );
238
+ });
239
+
240
+ test("composes end to end from a lastAlive", () => {
241
+ expect(
242
+ getRunnerLiveStatusLabel(
243
+ getRunnerLiveStatus(OneUptimeDate.getCurrentDate()),
244
+ ),
245
+ ).toBe("Connected");
246
+
247
+ expect(getRunnerLiveStatusLabel(getRunnerLiveStatus(null))).toBe(
248
+ "Never connected",
249
+ );
250
+
251
+ expect(getRunnerLiveStatusLabel(getRunnerLiveStatus(minutesAgo(60)))).toBe(
252
+ "Disconnected",
253
+ );
254
+ });
255
+ });
256
+
257
+ describe("the alive window is shared with the server", () => {
258
+ const RUNNER_SERVICE: string = path.join(
259
+ __dirname,
260
+ "..",
261
+ "..",
262
+ "..",
263
+ "Server",
264
+ "Services",
265
+ "RunnerService.ts",
266
+ );
267
+
268
+ test("the window is five minutes", () => {
269
+ expect(RUNNER_ALIVE_WINDOW_IN_MINUTES).toBe(5);
270
+ });
271
+
272
+ /*
273
+ * The point of moving this constant into Types was that the dashboard and
274
+ * the server answer "is this Runner alive" identically. A server that hands
275
+ * work to a Runner the dashboard is drawing as Disconnected — or refuses
276
+ * work to one drawn as Connected — is worse than either answer alone, and
277
+ * that drift is invisible until someone is debugging a runbook that never
278
+ * ran. So: RunnerService must IMPORT the constant, not redeclare it.
279
+ */
280
+ test("RunnerService imports the constant instead of declaring its own", () => {
281
+ const source: string = fs.readFileSync(RUNNER_SERVICE, "utf8");
282
+
283
+ expect(source).toContain('from "../../Types/Runner/RunnerLiveStatus"');
284
+ expect(source).toContain("RUNNER_ALIVE_WINDOW_IN_MINUTES");
285
+
286
+ // No local redeclaration may shadow the shared one.
287
+ expect(source).not.toMatch(/const\s+RUNNER_ALIVE_WINDOW_IN_MINUTES\s*:/);
288
+ });
289
+
290
+ test("RunnerService still gates its queries on the shared window", () => {
291
+ const source: string = fs.readFileSync(RUNNER_SERVICE, "utf8");
292
+
293
+ const usages: number = (
294
+ source.match(
295
+ /OneUptimeDate\.getSomeMinutesAgo\(\s*RUNNER_ALIVE_WINDOW_IN_MINUTES,?\s*\)/g,
296
+ ) || []
297
+ ).length;
298
+
299
+ // getOnlineAiCommandRunnersForProject and getOnlineCodeFixRunnerForProject.
300
+ expect(usages).toBe(2);
301
+ });
302
+
303
+ /*
304
+ * Guards the reason the UI stopped trusting the column at all: if someone
305
+ * later adds a job that ages connectionStatus out, this test failing is the
306
+ * prompt to revisit the derived status.
307
+ */
308
+ test("nothing in RunnerService writes connectionStatus back to Disconnected", () => {
309
+ const source: string = fs.readFileSync(RUNNER_SERVICE, "utf8");
310
+
311
+ const disconnectedWrites: number = (
312
+ source.match(
313
+ /connectionStatus\s*[:=]\s*RunnerConnectionStatus\.Disconnected/g,
314
+ ) || []
315
+ ).length;
316
+
317
+ // Exactly one: the onBeforeCreate default. No ageing-out writer exists.
318
+ expect(disconnectedWrites).toBe(1);
319
+ });
320
+ });
@@ -0,0 +1,285 @@
1
+ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import * as React from "react";
5
+ import Color from "../../../Types/Color";
6
+ import ObjectID from "../../../Types/ObjectID";
7
+ import Permission, { UserPermission } from "../../../Types/Permission";
8
+ import FieldType from "../../../UI/Components/Types/FieldType";
9
+ import FormFieldSchemaType from "../../../UI/Components/Forms/Types/FormFieldSchemaType";
10
+ import getJestMockFunction, { MockFunction } from "../../MockType";
11
+
12
+ /*
13
+ * The incident and alert "AI" settings pages render the investigation severity
14
+ * floor from a relation column. Detail.tsx had no branch for FieldType.Entity,
15
+ * so the whole IncidentSeverity model reached React as a child - which throws
16
+ * "Objects are not valid as a React child" (minified #31), trips the error
17
+ * boundary, and blanks the page for every project that had a floor set.
18
+ *
19
+ * This drives the real CardModelDetail -> ModelDetail -> Detail stack with the
20
+ * field configuration those two pages use, so the crash cannot come back, and
21
+ * pins the two things around it: the relation is actually asked for in the
22
+ * select, and the edit form is grouped into steps.
23
+ */
24
+
25
+ const getItemMock: MockFunction = getJestMockFunction();
26
+ const getListMock: MockFunction = getJestMockFunction();
27
+ const createOrUpdateMock: MockFunction = getJestMockFunction();
28
+
29
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
30
+ return {
31
+ __esModule: true,
32
+ default: {
33
+ getItem: (...args: Array<any>) => {
34
+ return getItemMock(...args);
35
+ },
36
+ getList: (...args: Array<any>) => {
37
+ return getListMock(...args);
38
+ },
39
+ createOrUpdate: (...args: Array<any>) => {
40
+ return createOrUpdateMock(...args);
41
+ },
42
+ },
43
+ };
44
+ });
45
+
46
+ jest.mock("../../../UI/Components/Toast/ToastInit", () => {
47
+ return {
48
+ __esModule: true,
49
+ default: () => {
50
+ return null;
51
+ },
52
+ ShowToastNotification: () => {
53
+ return null;
54
+ },
55
+ };
56
+ });
57
+
58
+ const OWNER_PERMISSIONS: Array<Permission> = [
59
+ Permission.Public,
60
+ Permission.User,
61
+ Permission.CurrentUser,
62
+ Permission.ProjectOwner,
63
+ ];
64
+
65
+ jest.mock("../../../UI/Utils/Permission", () => {
66
+ return {
67
+ __esModule: true,
68
+ default: {
69
+ getAllPermissions: () => {
70
+ return OWNER_PERMISSIONS;
71
+ },
72
+ getProjectPermissions: () => {
73
+ return {
74
+ permissions: OWNER_PERMISSIONS.map((permission: Permission) => {
75
+ return {
76
+ permission,
77
+ labelIds: [],
78
+ _type: "UserPermission",
79
+ } as UserPermission;
80
+ }),
81
+ };
82
+ },
83
+ getGlobalPermissions: () => {
84
+ return { globalPermissions: OWNER_PERMISSIONS };
85
+ },
86
+ },
87
+ };
88
+ });
89
+
90
+ jest.mock("../../../UI/Utils/User", () => {
91
+ return {
92
+ __esModule: true,
93
+ default: {
94
+ isMasterAdmin: () => {
95
+ return false;
96
+ },
97
+ },
98
+ };
99
+ });
100
+
101
+ import CardModelDetail from "../../../UI/Components/ModelDetail/CardModelDetail";
102
+ import IncidentSeverity from "../../../Models/DatabaseModels/IncidentSeverity";
103
+ import Project from "../../../Models/DatabaseModels/Project";
104
+
105
+ // These render real components that fetch, so give the waits room on a loaded box.
106
+ const WAIT_TIMEOUT: number = 20000;
107
+
108
+ const PROJECT_ID: ObjectID = new ObjectID(
109
+ "22222222-2222-4222-8222-222222222222",
110
+ );
111
+
112
+ const SEVERITY_ID: ObjectID = new ObjectID(
113
+ "33333333-3333-4333-8333-333333333333",
114
+ );
115
+
116
+ type RenderCardFunction = () => void;
117
+
118
+ const renderCard: RenderCardFunction = (): void => {
119
+ render(
120
+ <CardModelDetail<Project>
121
+ name="Automatic Incident Investigation"
122
+ cardProps={{
123
+ title: "Automatic Incident Investigation",
124
+ description: "Investigate every new incident.",
125
+ }}
126
+ isEditable={true}
127
+ editButtonText={"Update"}
128
+ formSteps={[
129
+ { title: "Investigation", id: "investigation" },
130
+ { title: "Limits", id: "limits" },
131
+ { title: "Fix Tasks", id: "fix-tasks" },
132
+ ]}
133
+ formFields={[
134
+ {
135
+ field: { enableAutomaticIncidentInvestigation: true },
136
+ stepId: "investigation",
137
+ title: "Automatically Investigate Incidents",
138
+ required: false,
139
+ fieldType: FormFieldSchemaType.Toggle,
140
+ },
141
+ {
142
+ field: { incidentInvestigationMinimumSeverity: true },
143
+ stepId: "investigation",
144
+ title: "Minimum Severity To Investigate",
145
+ required: false,
146
+ fieldType: FormFieldSchemaType.Dropdown,
147
+ dropdownModal: {
148
+ type: IncidentSeverity,
149
+ labelField: "name",
150
+ valueField: "_id",
151
+ },
152
+ placeholder: "Every severity",
153
+ },
154
+ {
155
+ field: { aiMaxConcurrentInvestigations: true },
156
+ stepId: "limits",
157
+ title: "Max Concurrent Investigations",
158
+ required: false,
159
+ fieldType: FormFieldSchemaType.Number,
160
+ placeholder: "3",
161
+ },
162
+ {
163
+ field: { aiDailyFixTaskLimit: true },
164
+ stepId: "fix-tasks",
165
+ title: "Daily AI Fix Task Limit",
166
+ required: false,
167
+ fieldType: FormFieldSchemaType.Number,
168
+ placeholder: "25",
169
+ },
170
+ ]}
171
+ modelDetailProps={{
172
+ modelType: Project,
173
+ id: "model-detail-project-incident-ai-settings",
174
+ modelId: PROJECT_ID,
175
+ fields: [
176
+ {
177
+ field: { enableAutomaticIncidentInvestigation: true },
178
+ title: "Automatically Investigate Incidents",
179
+ placeholder: "Disabled",
180
+ fieldType: FieldType.Boolean,
181
+ },
182
+ {
183
+ field: {
184
+ incidentInvestigationMinimumSeverity: {
185
+ name: true,
186
+ color: true,
187
+ },
188
+ },
189
+ title: "Minimum Severity To Investigate",
190
+ placeholder: "Every severity",
191
+ fieldType: FieldType.Entity,
192
+ },
193
+ ],
194
+ }}
195
+ />,
196
+ );
197
+ };
198
+
199
+ type LoadedProjectFunction = () => Project;
200
+
201
+ const loadedProject: LoadedProjectFunction = (): Project => {
202
+ const severity: IncidentSeverity = new IncidentSeverity();
203
+ severity.id = SEVERITY_ID;
204
+ severity.name = "Critical";
205
+ severity.color = new Color("#ef4444");
206
+
207
+ const project: Project = new Project();
208
+ project.id = PROJECT_ID;
209
+ project.enableAutomaticIncidentInvestigation = true;
210
+ project.incidentInvestigationMinimumSeverity = severity;
211
+
212
+ return project;
213
+ };
214
+
215
+ describe("AI investigation settings card", () => {
216
+ beforeEach(() => {
217
+ getItemMock.mockReset();
218
+ getListMock.mockReset();
219
+ createOrUpdateMock.mockReset();
220
+ getItemMock.mockResolvedValue(loadedProject());
221
+ getListMock.mockResolvedValue({ data: [], count: 0, skip: 0, limit: 10 });
222
+ createOrUpdateMock.mockResolvedValue({ data: {} });
223
+ });
224
+
225
+ it("shows the severity floor instead of blanking the page on a relation", async () => {
226
+ renderCard();
227
+
228
+ const badge: HTMLElement = await screen.findByText(
229
+ "Critical",
230
+ {},
231
+ { timeout: WAIT_TIMEOUT },
232
+ );
233
+
234
+ expect(badge).toBeDefined();
235
+ // The error boundary text that used to replace the whole page.
236
+ expect(screen.queryByText(/unexpected error/i)).toBeNull();
237
+ });
238
+
239
+ it("asks the API for the relation columns the card renders", async () => {
240
+ renderCard();
241
+
242
+ await waitFor(
243
+ () => {
244
+ expect(getItemMock).toHaveBeenCalled();
245
+ },
246
+ { timeout: WAIT_TIMEOUT },
247
+ );
248
+
249
+ const detailCall: any = (getItemMock.mock.calls as Array<Array<any>>).find(
250
+ (call: Array<any>) => {
251
+ return call[0]?.select?.["incidentInvestigationMinimumSeverity"];
252
+ },
253
+ );
254
+
255
+ expect(detailCall).toBeDefined();
256
+ expect(detailCall[0].select.incidentInvestigationMinimumSeverity).toEqual({
257
+ name: true,
258
+ color: true,
259
+ });
260
+ });
261
+
262
+ it("groups the edit form into steps", async () => {
263
+ renderCard();
264
+
265
+ await userEvent.click(
266
+ await screen.findByText("Update", {}, { timeout: WAIT_TIMEOUT }),
267
+ );
268
+
269
+ await waitFor(
270
+ () => {
271
+ expect(screen.getByText("Investigation")).toBeDefined();
272
+ },
273
+ { timeout: WAIT_TIMEOUT },
274
+ );
275
+
276
+ expect(screen.getByText("Limits")).toBeDefined();
277
+ expect(screen.getByText("Fix Tasks")).toBeDefined();
278
+
279
+ /*
280
+ * Only the first step's fields are on screen - which is the point of
281
+ * steps, and proves stepId actually took rather than being ignored.
282
+ */
283
+ expect(screen.queryByText("Daily AI Fix Task Limit")).toBeNull();
284
+ });
285
+ });