@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
@@ -571,10 +571,19 @@ export default class OnCallDutyPolicyScheduleLayer extends BaseModel {
571
571
  Permission.EditOnCallDutyPolicyScheduleLayer,
572
572
  ],
573
573
  })
574
+ /*
575
+ * The default is `.toJSON()` rather than the Recurring instance itself. Both
576
+ * serialize to the identical JSON, so the DDL is unchanged — but TypeORM
577
+ * compares a jsonb default by deep-comparing the entity default against the
578
+ * value parsed back out of the database, and that comparison rejects any pair
579
+ * whose constructors differ (OrmUtils.compare2Objects). An instance never
580
+ * equals the plain object Postgres hands back, so every generated migration
581
+ * re-emitted this SET DEFAULT as phantom drift. A plain object compares equal.
582
+ */
574
583
  @Column({
575
584
  nullable: false,
576
585
  type: ColumnType.JSON,
577
- default: Recurring.getDefault(),
586
+ default: Recurring.getDefault().toJSON(),
578
587
  transformer: Recurring.getDatabaseTransformer(),
579
588
  })
580
589
  public rotation?: Recurring = undefined;
@@ -654,10 +663,11 @@ export default class OnCallDutyPolicyScheduleLayer extends BaseModel {
654
663
  Permission.EditOnCallDutyPolicyScheduleLayer,
655
664
  ],
656
665
  })
666
+ // Plain object, not the instance — see the note on `rotation` above.
657
667
  @Column({
658
668
  nullable: false,
659
669
  type: ColumnType.JSON,
660
- default: RestrictionTimes.getDefault(),
670
+ default: RestrictionTimes.getDefault().toJSON(),
661
671
  transformer: RestrictionTimes.getDatabaseTransformer(),
662
672
  })
663
673
  public restrictionTimes?: RestrictionTimes = undefined;
@@ -1,10 +1,20 @@
1
1
  import dataSourceOptions from "./DataSourceOptions";
2
2
  import { DataSource } from "typeorm";
3
3
 
4
+ /*
5
+ * The app reaches Postgres over the compose network as `postgres:5432`, but the
6
+ * migration CLI runs on the host, where the same server is published on
7
+ * localhost:5400 — hence the defaults below.
8
+ *
9
+ * DATABASE_MIGRATIONS_HOST / DATABASE_MIGRATIONS_PORT (already in
10
+ * config.example.env, already honoured by migration-run.sh) override them, so
11
+ * the schema-drift job can point this at a CI service container on a different
12
+ * port without editing the file.
13
+ */
4
14
  const dataSourceOptionToMigrate: any = {
5
15
  ...dataSourceOptions,
6
- host: "localhost",
7
- port: 5400,
16
+ host: process.env["DATABASE_MIGRATIONS_HOST"] || "localhost",
17
+ port: parseInt(process.env["DATABASE_MIGRATIONS_PORT"] || "5400", 10),
8
18
  };
9
19
 
10
20
  const PostgresDataSource: DataSource = new DataSource(
@@ -265,6 +265,68 @@ export class Service extends DatabaseService<Model> {
265
265
  });
266
266
  }
267
267
 
268
+ /*
269
+ * THE rule for "may this project use this provider?": a global provider is
270
+ * shared with everyone, and a project-owned provider belongs only to its
271
+ * own project. Every caller that honours a caller-supplied llmProviderId
272
+ * routes through this one predicate so the checks cannot drift apart —
273
+ * a second, subtly different copy of this comparison is how one entry point
274
+ * ends up accepting a provider another one rejects.
275
+ */
276
+ private isProviderUsableBy(provider: Model, projectId: ObjectID): boolean {
277
+ if (provider.isGlobalLlm === true) {
278
+ return true;
279
+ }
280
+
281
+ return Boolean(
282
+ provider.projectId &&
283
+ provider.projectId.toString() === projectId.toString(),
284
+ );
285
+ }
286
+
287
+ /*
288
+ * Can this project run against this provider id? Answers the question
289
+ * without loading secrets — callers that only need a yes/no (the runbook AI
290
+ * step validating a pinned provider before it runs) must not pull an apiKey
291
+ * into memory to get it. A provider that does not exist, or belongs to
292
+ * another project, is not usable.
293
+ */
294
+ @CaptureSpan()
295
+ public async isProviderUsableByProject(data: {
296
+ projectId: ObjectID;
297
+ llmProviderId: ObjectID;
298
+ }): Promise<boolean> {
299
+ /*
300
+ * _id is a uuid column: querying it with a non-uuid string is a Postgres
301
+ * cast error, not an empty result. A caller-supplied id reaches us
302
+ * straight from an unvalidated JSON config, so shape-check before the
303
+ * query and report "not usable" rather than throwing a driver error.
304
+ */
305
+ if (!ObjectID.isValidUUID(data.llmProviderId.toString())) {
306
+ return false;
307
+ }
308
+
309
+ const provider: Model | null = await this.findOneBy({
310
+ query: {
311
+ _id: data.llmProviderId.toString(),
312
+ },
313
+ select: {
314
+ _id: true,
315
+ projectId: true,
316
+ isGlobalLlm: true,
317
+ },
318
+ props: {
319
+ isRoot: true,
320
+ },
321
+ });
322
+
323
+ if (!provider) {
324
+ return false;
325
+ }
326
+
327
+ return this.isProviderUsableBy(provider, data.projectId);
328
+ }
329
+
268
330
  /*
269
331
  * Resolve the provider to use for a chat turn. When the user has explicitly
270
332
  * chosen a provider (llmProviderId), use it — but only if it is actually
@@ -278,7 +340,11 @@ export class Service extends DatabaseService<Model> {
278
340
  projectId: ObjectID;
279
341
  llmProviderId?: ObjectID | undefined;
280
342
  }): Promise<Model | null> {
281
- if (data.llmProviderId) {
343
+ if (
344
+ data.llmProviderId &&
345
+ // A non-uuid id would be a Postgres cast error, not a miss. Fall back.
346
+ ObjectID.isValidUUID(data.llmProviderId.toString())
347
+ ) {
282
348
  const provider: Model | null = await this.findOneBy({
283
349
  query: {
284
350
  _id: data.llmProviderId.toString(),
@@ -300,14 +366,8 @@ export class Service extends DatabaseService<Model> {
300
366
  },
301
367
  });
302
368
 
303
- if (provider) {
304
- const isGlobal: boolean = provider.isGlobalLlm === true;
305
- const belongsToProject: boolean =
306
- provider.projectId?.toString() === data.projectId.toString();
307
-
308
- if (isGlobal || belongsToProject) {
309
- return provider;
310
- }
369
+ if (provider && this.isProviderUsableBy(provider, data.projectId)) {
370
+ return provider;
311
371
  }
312
372
  // Fall through to default resolution when the id is invalid/inaccessible.
313
373
  }
@@ -11,13 +11,7 @@ import OneUptimeDate from "../../Types/Date";
11
11
  import { JSONObject } from "../../Types/JSON";
12
12
  import QueryHelper from "../Types/Database/QueryHelper";
13
13
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
14
-
15
- /*
16
- * How recently a Runner must have heartbeated to count as online. Matches the
17
- * window AIAgentService.isAgentAlive uses, and comfortably clears the Runner's
18
- * 60s heartbeat interval.
19
- */
20
- const RUNNER_ALIVE_WINDOW_IN_MINUTES: number = 5;
14
+ import { RUNNER_ALIVE_WINDOW_IN_MINUTES } from "../../Types/Runner/RunnerLiveStatus";
21
15
 
22
16
  export class Service extends DatabaseService<Model> {
23
17
  public constructor() {
@@ -192,6 +192,18 @@ export default class CompareCriteria {
192
192
  return null;
193
193
  }
194
194
 
195
+ /*
196
+ * parseInt("abc") returns NaN rather than throwing, and NaN is typeof
197
+ * "number" — so without this guard a non-numeric threshold would leak
198
+ * past callers that only check for `=== null`, leaving every numeric
199
+ * comparison silently false (value > NaN is always false). Treat an
200
+ * unparseable value the same as a missing one so the caller can ignore
201
+ * it instead of firing a broken criterion.
202
+ */
203
+ if (Number.isNaN(threshold as number)) {
204
+ return null;
205
+ }
206
+
195
207
  return threshold as number;
196
208
  }
197
209
 
@@ -0,0 +1,119 @@
1
+ import "@testing-library/jest-dom";
2
+ import { render, screen } from "@testing-library/react";
3
+ import React from "react";
4
+ import { describe, expect, test } from "@jest/globals";
5
+ import RunnerInstallInstructions from "../../../../App/FeatureSet/Dashboard/src/Components/Runner/InstallInstructions";
6
+ import ObjectID from "../../../Types/ObjectID";
7
+
8
+ /*
9
+ * The setup card renders the docker run command with the Runner's secret key
10
+ * embedded in it. Reading that key is restricted to Project Owner, Project
11
+ * Admin and Runbook Admin (see the ColumnAccessControl on Runner.key), so for
12
+ * a Member or a Viewer the field simply is not in the API response.
13
+ *
14
+ * The Runners table asked for the key and passed `(item.key as string) || ""`
15
+ * straight through, so those users were handed a command ending in
16
+ * ONEUPTIME_RUNNER_KEY= with nothing after it — which copies cleanly, runs on
17
+ * the host, and fails there with an authentication error. These tests pin that
18
+ * no command is ever rendered without a key.
19
+ */
20
+
21
+ const RUNNER_ID: ObjectID = new ObjectID("abc-123");
22
+
23
+ type RenderWithKeyFunction = (key: string) => HTMLElement;
24
+
25
+ const renderWithKey: RenderWithKeyFunction = (key: string): HTMLElement => {
26
+ const { container } = render(
27
+ <RunnerInstallInstructions runnerId={RUNNER_ID} runnerKey={key} />,
28
+ );
29
+
30
+ return container;
31
+ };
32
+
33
+ describe("RunnerInstallInstructions", () => {
34
+ describe("when the key is readable", () => {
35
+ test("renders the docker run command", () => {
36
+ const container: HTMLElement = renderWithKey("super-secret-key");
37
+
38
+ expect(container.textContent).toContain("docker run");
39
+ expect(container.textContent).toContain("oneuptime/runner:release");
40
+ });
41
+
42
+ test("embeds the Runner id and key", () => {
43
+ const container: HTMLElement = renderWithKey("super-secret-key");
44
+
45
+ expect(container.textContent).toContain("ONEUPTIME_RUNNER_ID=abc-123");
46
+ expect(container.textContent).toContain(
47
+ "ONEUPTIME_RUNNER_KEY=super-secret-key",
48
+ );
49
+ });
50
+
51
+ test("does not render the permission warning", () => {
52
+ renderWithKey("super-secret-key");
53
+
54
+ expect(
55
+ screen.queryByText(/do not have permission/i),
56
+ ).not.toBeInTheDocument();
57
+ });
58
+
59
+ /*
60
+ * The container needs outbound HTTPS only; saying so here is what stops
61
+ * someone opening an inbound hole for it.
62
+ */
63
+ test("states that the Runner needs no inbound connections", () => {
64
+ const container: HTMLElement = renderWithKey("super-secret-key");
65
+
66
+ expect(container.textContent).toContain(
67
+ "does not accept inbound connections",
68
+ );
69
+ });
70
+
71
+ /*
72
+ * Added because "I ran the command and it still says Never connected" is
73
+ * the obvious next confusion once the never-connected state exists.
74
+ */
75
+ test("explains that the first heartbeat takes up to a minute", () => {
76
+ const container: HTMLElement = renderWithKey("super-secret-key");
77
+
78
+ expect(container.textContent).toContain("every 60 seconds");
79
+ expect(container.textContent).toContain("Connected");
80
+ });
81
+ });
82
+
83
+ describe("when the key is not readable", () => {
84
+ test("renders the permission warning instead", () => {
85
+ renderWithKey("");
86
+
87
+ expect(
88
+ screen.getByText(
89
+ "You do not have permission to view this Runner's key",
90
+ ),
91
+ ).toBeInTheDocument();
92
+ });
93
+
94
+ /*
95
+ * The load-bearing assertion: no command at all, rather than a command
96
+ * that is silently broken.
97
+ */
98
+ test("renders no docker command at all", () => {
99
+ const container: HTMLElement = renderWithKey("");
100
+
101
+ expect(container.textContent).not.toContain("docker run");
102
+ expect(container.textContent).not.toContain("ONEUPTIME_RUNNER_KEY");
103
+ });
104
+
105
+ test("never emits an empty ONEUPTIME_RUNNER_KEY=", () => {
106
+ const container: HTMLElement = renderWithKey("");
107
+
108
+ expect(container.textContent).not.toMatch(/ONEUPTIME_RUNNER_KEY=\s/);
109
+ });
110
+
111
+ test("says who can supply the command", () => {
112
+ const container: HTMLElement = renderWithKey("");
113
+
114
+ expect(container.textContent).toContain("Project Owner");
115
+ expect(container.textContent).toContain("Project Admin");
116
+ expect(container.textContent).toContain("Runbook Admin");
117
+ });
118
+ });
119
+ });
@@ -0,0 +1,274 @@
1
+ import "@testing-library/jest-dom";
2
+ import { render, screen } from "@testing-library/react";
3
+ import React from "react";
4
+ import { describe, expect, test } from "@jest/globals";
5
+ import RunnerStatusElement from "../../../../App/FeatureSet/Dashboard/src/Components/Runner/RunnerStatus";
6
+ import Runner, {
7
+ RunnerConnectionStatus,
8
+ } from "../../../Models/DatabaseModels/Runner";
9
+ import OneUptimeDate from "../../../Types/Date";
10
+ import { JSONObject } from "../../../Types/JSON";
11
+
12
+ /*
13
+ * The one component every Runner connection indicator in the dashboard now
14
+ * renders through. Three call sites used to hand-roll this, and all three read
15
+ * the persisted connectionStatus column — which is written on create and on
16
+ * the first heartbeat and never again. A Runner that died months ago still has
17
+ * it set to "connected".
18
+ *
19
+ * So the assertions that matter most here are the ones proving the rendered
20
+ * status tracks lastAlive and IGNORES connectionStatus, in both directions.
21
+ */
22
+
23
+ type RenderRunnerFunction = (
24
+ runner: Runner | JSONObject,
25
+ showLastSeen?: boolean,
26
+ ) => void;
27
+
28
+ const renderRunner: RenderRunnerFunction = (
29
+ runner: Runner | JSONObject,
30
+ showLastSeen?: boolean,
31
+ ): void => {
32
+ render(
33
+ showLastSeen === undefined ? (
34
+ <RunnerStatusElement runner={runner} />
35
+ ) : (
36
+ <RunnerStatusElement runner={runner} showLastSeen={showLastSeen} />
37
+ ),
38
+ );
39
+ };
40
+
41
+ type MinutesAgoFunction = (minutes: number) => Date;
42
+
43
+ const minutesAgo: MinutesAgoFunction = (minutes: number): Date => {
44
+ return OneUptimeDate.getSomeMinutesAgo(minutes);
45
+ };
46
+
47
+ type StatusTextFunction = () => string;
48
+
49
+ /*
50
+ * Statusbubble renders role="status" with aria-label "Status: <text>", so the
51
+ * accessible name is the assertion surface — it is what a screen reader
52
+ * announces, not just what happens to be in a div.
53
+ */
54
+ const statusText: StatusTextFunction = (): string => {
55
+ return screen.getByRole("status").getAttribute("aria-label") || "";
56
+ };
57
+
58
+ describe("RunnerStatusElement", () => {
59
+ describe("never connected", () => {
60
+ test("a Runner with no lastAlive reads 'Never connected'", () => {
61
+ renderRunner({ lastAlive: null });
62
+
63
+ expect(screen.getByText("Never connected")).toBeInTheDocument();
64
+ expect(statusText()).toBe("Status: Never connected");
65
+ });
66
+
67
+ test("a Runner with lastAlive absent entirely reads 'Never connected'", () => {
68
+ renderRunner({});
69
+
70
+ expect(screen.getByText("Never connected")).toBeInTheDocument();
71
+ });
72
+
73
+ /*
74
+ * The whole point of the third state. A Runner created ten seconds ago
75
+ * whose container has not been started is not a failure, and must not be
76
+ * dressed as one — it used to render the same red "Disconnected" as a
77
+ * Runner that crashed in production.
78
+ */
79
+ test("it is not called Disconnected", () => {
80
+ renderRunner({ lastAlive: null });
81
+
82
+ expect(screen.queryByText("Disconnected")).not.toBeInTheDocument();
83
+ expect(statusText()).not.toContain("Disconnected");
84
+ });
85
+
86
+ test("it is grey, not red", () => {
87
+ const { container } = render(
88
+ <RunnerStatusElement runner={{ lastAlive: null }} />,
89
+ );
90
+
91
+ const html: string = container.innerHTML;
92
+
93
+ // Gray500 #6b7280, Red #fd625e.
94
+ expect(html).toContain("107, 114, 128");
95
+ expect(html).not.toContain("253, 98, 94");
96
+ });
97
+
98
+ test("it does not animate", () => {
99
+ const { container } = render(
100
+ <RunnerStatusElement runner={{ lastAlive: null }} />,
101
+ );
102
+
103
+ expect(container.innerHTML).not.toContain("animate-ping");
104
+ });
105
+ });
106
+
107
+ describe("connected", () => {
108
+ test("a heartbeat right now reads 'Connected'", () => {
109
+ renderRunner({ lastAlive: OneUptimeDate.getCurrentDate() });
110
+
111
+ expect(screen.getByText("Connected")).toBeInTheDocument();
112
+ expect(statusText()).toBe("Status: Connected");
113
+ });
114
+
115
+ test("a heartbeat four minutes ago still reads 'Connected'", () => {
116
+ renderRunner({ lastAlive: minutesAgo(4) });
117
+
118
+ expect(screen.getByText("Connected")).toBeInTheDocument();
119
+ });
120
+
121
+ test("it is green and animating", () => {
122
+ const { container } = render(
123
+ <RunnerStatusElement
124
+ runner={{ lastAlive: OneUptimeDate.getCurrentDate() }}
125
+ />,
126
+ );
127
+
128
+ // Green #2ab57d.
129
+ expect(container.innerHTML).toContain("42, 181, 125");
130
+ expect(container.innerHTML).toContain("animate-ping");
131
+ });
132
+ });
133
+
134
+ describe("disconnected", () => {
135
+ test("a heartbeat an hour ago reads 'Disconnected'", () => {
136
+ renderRunner({ lastAlive: minutesAgo(60) });
137
+
138
+ expect(statusText()).toContain("Disconnected");
139
+ });
140
+
141
+ /*
142
+ * "How long has it been gone" is the entire question when a Runner drops,
143
+ * and the detail card has no other column carrying it.
144
+ */
145
+ test("it appends how long ago the last heartbeat was", () => {
146
+ renderRunner({ lastAlive: minutesAgo(60) });
147
+
148
+ expect(statusText()).toBe("Status: Disconnected · an hour ago");
149
+ });
150
+
151
+ test("a day-old heartbeat says a day ago", () => {
152
+ renderRunner({ lastAlive: minutesAgo(60 * 24) });
153
+
154
+ expect(statusText()).toBe("Status: Disconnected · a day ago");
155
+ });
156
+
157
+ /*
158
+ * In the Runners table a Last Seen column sits directly beside this one,
159
+ * so repeating the relative time would be noise.
160
+ */
161
+ test("showLastSeen={false} suppresses the relative time", () => {
162
+ renderRunner({ lastAlive: minutesAgo(60) }, false);
163
+
164
+ expect(statusText()).toBe("Status: Disconnected");
165
+ });
166
+
167
+ test("showLastSeen={true} is the same as the default", () => {
168
+ renderRunner({ lastAlive: minutesAgo(60) }, true);
169
+
170
+ expect(statusText()).toBe("Status: Disconnected · an hour ago");
171
+ });
172
+
173
+ test("it is red and does not animate", () => {
174
+ const { container } = render(
175
+ <RunnerStatusElement runner={{ lastAlive: minutesAgo(60) }} />,
176
+ );
177
+
178
+ // Red #fd625e.
179
+ expect(container.innerHTML).toContain("253, 98, 94");
180
+ expect(container.innerHTML).not.toContain("animate-ping");
181
+ });
182
+ });
183
+
184
+ /*
185
+ * The regression suite. Each of these is a shape the old inline
186
+ * implementations got wrong.
187
+ */
188
+ describe("connectionStatus is ignored", () => {
189
+ test("stale lastAlive reads Disconnected even when connectionStatus says Connected", () => {
190
+ renderRunner({
191
+ connectionStatus: RunnerConnectionStatus.Connected,
192
+ lastAlive: minutesAgo(60 * 24 * 90),
193
+ });
194
+
195
+ expect(statusText()).toContain("Disconnected");
196
+ expect(screen.queryByText("Connected")).not.toBeInTheDocument();
197
+ });
198
+
199
+ test("fresh lastAlive reads Connected even when connectionStatus says Disconnected", () => {
200
+ renderRunner({
201
+ connectionStatus: RunnerConnectionStatus.Disconnected,
202
+ lastAlive: OneUptimeDate.getCurrentDate(),
203
+ });
204
+
205
+ expect(statusText()).toBe("Status: Connected");
206
+ });
207
+
208
+ /*
209
+ * Exactly what RunnerService.onBeforeCreate produces: connectionStatus
210
+ * defaulted to Disconnected, lastAlive still null. The user has not
211
+ * failed at anything yet — they have not run the Docker command.
212
+ */
213
+ test("a freshly created Runner reads Never connected, not Disconnected", () => {
214
+ renderRunner({
215
+ connectionStatus: RunnerConnectionStatus.Disconnected,
216
+ lastAlive: null,
217
+ });
218
+
219
+ expect(statusText()).toBe("Status: Never connected");
220
+ });
221
+
222
+ test("connectionStatus alone never produces a Connected bubble", () => {
223
+ renderRunner({ connectionStatus: RunnerConnectionStatus.Connected });
224
+
225
+ expect(statusText()).toBe("Status: Never connected");
226
+ });
227
+ });
228
+
229
+ describe("input shapes", () => {
230
+ /*
231
+ * The table hands over a hydrated model; ModelDetail's getElement hands
232
+ * over the same. The API layer hands over a plain object with an ISO
233
+ * string. All three have to work.
234
+ */
235
+ test("accepts a hydrated Runner model", () => {
236
+ const runner: Runner = new Runner();
237
+ runner.lastAlive = OneUptimeDate.getCurrentDate();
238
+
239
+ renderRunner(runner);
240
+
241
+ expect(statusText()).toBe("Status: Connected");
242
+ });
243
+
244
+ test("accepts a hydrated Runner model that never connected", () => {
245
+ renderRunner(new Runner());
246
+
247
+ expect(statusText()).toBe("Status: Never connected");
248
+ });
249
+
250
+ test("accepts an ISO string lastAlive", () => {
251
+ renderRunner({ lastAlive: new Date().toISOString() });
252
+
253
+ expect(statusText()).toBe("Status: Connected");
254
+ });
255
+
256
+ test("accepts a stale ISO string lastAlive", () => {
257
+ renderRunner({ lastAlive: minutesAgo(60).toISOString() });
258
+
259
+ expect(statusText()).toContain("Disconnected");
260
+ });
261
+
262
+ /*
263
+ * An unparseable timestamp must not be rendered as a healthy Runner. It
264
+ * falls to Disconnected, and the appended relative time is suppressed
265
+ * rather than printed as "Invalid date".
266
+ */
267
+ test("an unparseable lastAlive is Disconnected and prints no garbage time", () => {
268
+ renderRunner({ lastAlive: "not-a-date" });
269
+
270
+ expect(statusText()).toContain("Disconnected");
271
+ expect(statusText()).not.toContain("Invalid");
272
+ });
273
+ });
274
+ });