openlattice-ssh 0.0.3

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.
@@ -0,0 +1,778 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { SSHProvider } from "../src/ssh-provider";
3
+
4
+ // ── Mock ssh2 ───────────────────────────────────────────────────────
5
+
6
+ const mocks = vi.hoisted(() => {
7
+ const mockStream = {
8
+ on: vi.fn(),
9
+ stderr: { on: vi.fn() },
10
+ };
11
+
12
+ const mockSftp = {
13
+ readFile: vi.fn(),
14
+ writeFile: vi.fn(),
15
+ readdir: vi.fn(),
16
+ unlink: vi.fn(),
17
+ mkdir: vi.fn(),
18
+ };
19
+
20
+ const mockExec = vi.fn();
21
+ const mockSftpFn = vi.fn();
22
+ const mockConnect = vi.fn();
23
+ const mockEnd = vi.fn();
24
+ const mockOn = vi.fn();
25
+
26
+ return {
27
+ mockStream,
28
+ mockSftp,
29
+ mockExec,
30
+ mockSftpFn,
31
+ mockConnect,
32
+ mockEnd,
33
+ mockOn,
34
+ };
35
+ });
36
+
37
+ vi.mock("ssh2", () => ({
38
+ Client: vi.fn(function (this: any) {
39
+ this.exec = mocks.mockExec;
40
+ this.sftp = mocks.mockSftpFn;
41
+ this.connect = mocks.mockConnect;
42
+ this.end = mocks.mockEnd;
43
+ this.on = mocks.mockOn;
44
+ return this;
45
+ }),
46
+ }));
47
+
48
+ // Helper to set up mockExec to call the callback with a mock stream
49
+ function setupExec(stdout: string, exitCode = 0) {
50
+ mocks.mockExec.mockImplementation((_cmd: string, cb: Function) => {
51
+ const stream = {
52
+ on: vi.fn().mockImplementation(function (
53
+ this: any,
54
+ event: string,
55
+ handler: Function
56
+ ) {
57
+ if (event === "data") {
58
+ setTimeout(() => handler(Buffer.from(stdout)), 0);
59
+ }
60
+ if (event === "close") {
61
+ setTimeout(() => handler(exitCode), 5);
62
+ }
63
+ return this;
64
+ }),
65
+ stderr: {
66
+ on: vi.fn().mockReturnThis(),
67
+ },
68
+ };
69
+ cb(undefined, stream);
70
+ });
71
+ }
72
+
73
+ function setupExecWithStderr(
74
+ stdout: string,
75
+ stderr: string,
76
+ exitCode = 0
77
+ ) {
78
+ mocks.mockExec.mockImplementation((_cmd: string, cb: Function) => {
79
+ const stream = {
80
+ on: vi.fn().mockImplementation(function (
81
+ this: any,
82
+ event: string,
83
+ handler: Function
84
+ ) {
85
+ if (event === "data") {
86
+ setTimeout(() => handler(Buffer.from(stdout)), 0);
87
+ }
88
+ if (event === "close") {
89
+ setTimeout(() => handler(exitCode), 5);
90
+ }
91
+ return this;
92
+ }),
93
+ stderr: {
94
+ on: vi.fn().mockImplementation(function (
95
+ this: any,
96
+ event: string,
97
+ handler: Function
98
+ ) {
99
+ if (event === "data") {
100
+ setTimeout(() => handler(Buffer.from(stderr)), 0);
101
+ }
102
+ return this;
103
+ }),
104
+ },
105
+ };
106
+ cb(undefined, stream);
107
+ });
108
+ }
109
+
110
+ // ── Tests ───────────────────────────────────────────────────────────
111
+
112
+ describe("SSHProvider", () => {
113
+ let provider: SSHProvider;
114
+
115
+ const defaultConfig = {
116
+ hosts: [
117
+ { host: "192.168.1.100", port: 22, username: "testuser" },
118
+ ],
119
+ defaultUser: "defaultuser",
120
+ };
121
+
122
+ beforeEach(() => {
123
+ vi.clearAllMocks();
124
+ // Reset the mock to fire "ready" on connect
125
+ mocks.mockOn.mockImplementation(function (
126
+ this: any,
127
+ event: string,
128
+ cb: Function
129
+ ) {
130
+ if (event === "ready") {
131
+ setTimeout(() => cb(), 0);
132
+ }
133
+ return this;
134
+ });
135
+ setupExec("", 0);
136
+ provider = new SSHProvider(defaultConfig);
137
+ });
138
+
139
+ describe("constructor", () => {
140
+ it("sets name to 'ssh'", () => {
141
+ expect(provider.name).toBe("ssh");
142
+ });
143
+
144
+ it("declares expected capabilities", () => {
145
+ expect(provider.capabilities.restart).toBe(true);
146
+ expect(provider.capabilities.pause).toBe(false);
147
+ expect(provider.capabilities.snapshot).toBe(false);
148
+ expect(provider.capabilities.logs).toBe(false);
149
+ expect(provider.capabilities.tailscale).toBe(true);
150
+ expect(provider.capabilities.persistentStorage).toBe(true);
151
+ expect(provider.capabilities.architectures).toContain("x86_64");
152
+ expect(provider.capabilities.architectures).toContain("arm64");
153
+ });
154
+
155
+ it("detects GPU from host config", () => {
156
+ const gpuProvider = new SSHProvider({
157
+ hosts: [{ host: "gpu-host", gpuAvailable: true }],
158
+ });
159
+ expect(gpuProvider.capabilities.gpu).toBe(true);
160
+ });
161
+
162
+ it("defaults gpu to false when no hosts have GPU", () => {
163
+ expect(provider.capabilities.gpu).toBe(false);
164
+ });
165
+
166
+ it("throws if no hosts configured", () => {
167
+ expect(() => new SSHProvider({ hosts: [] })).toThrow(
168
+ "at least one host"
169
+ );
170
+ });
171
+ });
172
+
173
+ describe("provision", () => {
174
+ it("creates working directory and returns node", async () => {
175
+ const node = await provider.provision({
176
+ runtime: { image: "any" },
177
+ });
178
+
179
+ expect(node.externalId).toMatch(/^192\.168\.1\.100:22\/ssh-/);
180
+ expect(node.endpoints).toHaveLength(1);
181
+ expect(node.endpoints[0].host).toBe("192.168.1.100");
182
+ expect(node.endpoints[0].port).toBe(22);
183
+ });
184
+
185
+ it("calls mkdir on the remote host", async () => {
186
+ await provider.provision({
187
+ runtime: { image: "any" },
188
+ });
189
+
190
+ expect(mocks.mockExec).toHaveBeenCalled();
191
+ const cmd = mocks.mockExec.mock.calls[0][0];
192
+ expect(cmd).toContain("mkdir -p /tmp/openlattice/");
193
+ });
194
+
195
+ it("runs initial command in background", async () => {
196
+ // First call: mkdir, second call: nohup
197
+ let callIdx = 0;
198
+ mocks.mockExec.mockImplementation((cmd: string, cb: Function) => {
199
+ const stdout = callIdx === 1 ? "1234\n" : "";
200
+ callIdx++;
201
+ const stream = {
202
+ on: vi.fn().mockImplementation(function (
203
+ this: any,
204
+ event: string,
205
+ handler: Function
206
+ ) {
207
+ if (event === "data") {
208
+ setTimeout(() => handler(Buffer.from(stdout)), 0);
209
+ }
210
+ if (event === "close") {
211
+ setTimeout(() => handler(0), 5);
212
+ }
213
+ return this;
214
+ }),
215
+ stderr: { on: vi.fn().mockReturnThis() },
216
+ };
217
+ cb(undefined, stream);
218
+ });
219
+
220
+ await provider.provision({
221
+ runtime: {
222
+ image: "any",
223
+ command: ["python", "server.py"],
224
+ },
225
+ });
226
+
227
+ expect(mocks.mockExec).toHaveBeenCalledTimes(2);
228
+ const nohupCmd = mocks.mockExec.mock.calls[1][0];
229
+ expect(nohupCmd).toContain("nohup python server.py");
230
+ });
231
+
232
+ it("rejects GPU spec when no GPU hosts", async () => {
233
+ await expect(
234
+ provider.provision({
235
+ runtime: { image: "any" },
236
+ gpu: { count: 1 },
237
+ })
238
+ ).rejects.toThrow("no hosts with GPU");
239
+ });
240
+
241
+ it("selects hosts by labels", async () => {
242
+ const multiHostProvider = new SSHProvider({
243
+ hosts: [
244
+ { host: "host-a", labels: { region: "us-east" } },
245
+ { host: "host-b", labels: { region: "eu-west" } },
246
+ ],
247
+ });
248
+
249
+ const node = await multiHostProvider.provision({
250
+ runtime: { image: "any" },
251
+ labels: { region: "eu-west" },
252
+ });
253
+
254
+ expect(node.externalId).toContain("host-b");
255
+ });
256
+
257
+ it("throws when no hosts match labels", async () => {
258
+ await expect(
259
+ provider.provision({
260
+ runtime: { image: "any" },
261
+ labels: { region: "unknown" },
262
+ })
263
+ ).rejects.toThrow("no hosts matching labels");
264
+ });
265
+
266
+ it("calls ensureTailscale when tailscale is true", async () => {
267
+ // Track exec calls to verify tailscale commands
268
+ const execCmds: string[] = [];
269
+ mocks.mockExec.mockImplementation((cmd: string, cb: Function) => {
270
+ execCmds.push(cmd);
271
+ const stdout = cmd.includes("which tailscale")
272
+ ? "/usr/bin/tailscale"
273
+ : cmd.includes("pgrep tailscaled")
274
+ ? ""
275
+ : "";
276
+ const stream = {
277
+ on: vi.fn().mockImplementation(function (
278
+ this: any,
279
+ event: string,
280
+ handler: Function
281
+ ) {
282
+ if (event === "data" && stdout) {
283
+ setTimeout(() => handler(Buffer.from(stdout)), 0);
284
+ }
285
+ if (event === "close") {
286
+ setTimeout(() => handler(0), 5);
287
+ }
288
+ return this;
289
+ }),
290
+ stderr: { on: vi.fn().mockReturnThis() },
291
+ };
292
+ cb(undefined, stream);
293
+ });
294
+
295
+ await provider.provision({
296
+ runtime: { image: "any" },
297
+ network: { tailscale: true },
298
+ });
299
+
300
+ // Should have called: mkdir, which tailscale, pgrep tailscaled
301
+ expect(execCmds.some((c) => c.includes("which tailscale"))).toBe(true);
302
+ expect(execCmds.some((c) => c.includes("pgrep tailscaled"))).toBe(true);
303
+ });
304
+
305
+ it("installs tailscale when not found on host", async () => {
306
+ const execCmds: string[] = [];
307
+ let callIdx = 0;
308
+ mocks.mockExec.mockImplementation((cmd: string, cb: Function) => {
309
+ execCmds.push(cmd);
310
+ const isWhichCheck = cmd.includes("which tailscale");
311
+ const exitCode = isWhichCheck && callIdx === 1 ? 1 : 0;
312
+ callIdx++;
313
+ const stream = {
314
+ on: vi.fn().mockImplementation(function (
315
+ this: any,
316
+ event: string,
317
+ handler: Function
318
+ ) {
319
+ if (event === "close") {
320
+ setTimeout(() => handler(exitCode), 5);
321
+ }
322
+ return this;
323
+ }),
324
+ stderr: { on: vi.fn().mockReturnThis() },
325
+ };
326
+ cb(undefined, stream);
327
+ });
328
+
329
+ await provider.provision({
330
+ runtime: { image: "any" },
331
+ network: { tailscale: true },
332
+ });
333
+
334
+ expect(
335
+ execCmds.some((c) => c.includes("tailscale.com/install.sh"))
336
+ ).toBe(true);
337
+ });
338
+
339
+ it("does not call ensureTailscale when tailscale is not set", async () => {
340
+ const execCmds: string[] = [];
341
+ mocks.mockExec.mockImplementation((cmd: string, cb: Function) => {
342
+ execCmds.push(cmd);
343
+ const stream = {
344
+ on: vi.fn().mockImplementation(function (
345
+ this: any,
346
+ event: string,
347
+ handler: Function
348
+ ) {
349
+ if (event === "close") {
350
+ setTimeout(() => handler(0), 5);
351
+ }
352
+ return this;
353
+ }),
354
+ stderr: { on: vi.fn().mockReturnThis() },
355
+ };
356
+ cb(undefined, stream);
357
+ });
358
+
359
+ await provider.provision({
360
+ runtime: { image: "any" },
361
+ });
362
+
363
+ // Should only have mkdir, no tailscale checks
364
+ expect(execCmds.some((c) => c.includes("tailscale"))).toBe(false);
365
+ });
366
+
367
+ it("runs tailscale up --authkey when tailscaleAuthKey is set", async () => {
368
+ const execCmds: string[] = [];
369
+ mocks.mockExec.mockImplementation((cmd: string, cb: Function) => {
370
+ execCmds.push(cmd);
371
+ const stdout = cmd.includes("which tailscale")
372
+ ? "/usr/bin/tailscale"
373
+ : "";
374
+ const stream = {
375
+ on: vi.fn().mockImplementation(function (
376
+ this: any,
377
+ event: string,
378
+ handler: Function
379
+ ) {
380
+ if (event === "data" && stdout) {
381
+ setTimeout(() => handler(Buffer.from(stdout)), 0);
382
+ }
383
+ if (event === "close") {
384
+ setTimeout(() => handler(0), 5);
385
+ }
386
+ return this;
387
+ }),
388
+ stderr: { on: vi.fn().mockReturnThis() },
389
+ };
390
+ cb(undefined, stream);
391
+ });
392
+
393
+ await provider.provision({
394
+ runtime: { image: "any" },
395
+ network: { tailscaleAuthKey: "tskey-auth-abc123" },
396
+ });
397
+
398
+ // Should have called tailscale up with the auth key
399
+ expect(
400
+ execCmds.some((c) => c.includes("tailscale up") && c.includes("tskey-auth-abc123"))
401
+ ).toBe(true);
402
+ });
403
+
404
+ it("enables tailscale setup when only tailscaleAuthKey is set", async () => {
405
+ const execCmds: string[] = [];
406
+ mocks.mockExec.mockImplementation((cmd: string, cb: Function) => {
407
+ execCmds.push(cmd);
408
+ const stdout = cmd.includes("which tailscale")
409
+ ? "/usr/bin/tailscale"
410
+ : "";
411
+ const stream = {
412
+ on: vi.fn().mockImplementation(function (
413
+ this: any,
414
+ event: string,
415
+ handler: Function
416
+ ) {
417
+ if (event === "data" && stdout) {
418
+ setTimeout(() => handler(Buffer.from(stdout)), 0);
419
+ }
420
+ if (event === "close") {
421
+ setTimeout(() => handler(0), 5);
422
+ }
423
+ return this;
424
+ }),
425
+ stderr: { on: vi.fn().mockReturnThis() },
426
+ };
427
+ cb(undefined, stream);
428
+ });
429
+
430
+ await provider.provision({
431
+ runtime: { image: "any" },
432
+ network: { tailscaleAuthKey: "tskey-auth-xyz" },
433
+ });
434
+
435
+ // Should have called ensureTailscale (which tailscale check)
436
+ expect(execCmds.some((c) => c.includes("which tailscale"))).toBe(true);
437
+ // And tailscale up
438
+ expect(
439
+ execCmds.some((c) => c.includes("tailscale up"))
440
+ ).toBe(true);
441
+ });
442
+ });
443
+
444
+ describe("exec", () => {
445
+ it("runs command on remote host", async () => {
446
+ setupExec("hello\n", 0);
447
+ const node = await provider.provision({
448
+ runtime: { image: "any" },
449
+ });
450
+
451
+ setupExec("world\n", 0);
452
+ const result = await provider.exec(node.externalId, [
453
+ "echo",
454
+ "world",
455
+ ]);
456
+
457
+ expect(result.exitCode).toBe(0);
458
+ expect(result.stdout).toBe("world\n");
459
+ });
460
+
461
+ it("captures stderr", async () => {
462
+ setupExec("", 0);
463
+ const node = await provider.provision({
464
+ runtime: { image: "any" },
465
+ });
466
+
467
+ setupExecWithStderr("", "err\n", 1);
468
+ const result = await provider.exec(node.externalId, [
469
+ "sh",
470
+ "-c",
471
+ "echo err >&2; exit 1",
472
+ ]);
473
+
474
+ expect(result.exitCode).toBe(1);
475
+ expect(result.stderr).toBe("err\n");
476
+ });
477
+
478
+ it("prepends cwd to command", async () => {
479
+ setupExec("", 0);
480
+ const node = await provider.provision({
481
+ runtime: { image: "any" },
482
+ });
483
+
484
+ setupExec("", 0);
485
+ await provider.exec(node.externalId, ["ls"], { cwd: "/app" });
486
+
487
+ const cmd = mocks.mockExec.mock.calls[mocks.mockExec.mock.calls.length - 1][0];
488
+ expect(cmd).toContain("cd /app");
489
+ });
490
+
491
+ it("prepends env vars to command", async () => {
492
+ setupExec("", 0);
493
+ const node = await provider.provision({
494
+ runtime: { image: "any" },
495
+ });
496
+
497
+ setupExec("", 0);
498
+ await provider.exec(node.externalId, ["env"], {
499
+ env: { FOO: "bar" },
500
+ });
501
+
502
+ const cmd = mocks.mockExec.mock.calls[mocks.mockExec.mock.calls.length - 1][0];
503
+ expect(cmd).toContain("FOO='bar'");
504
+ });
505
+
506
+ it("throws for unknown node", async () => {
507
+ await expect(
508
+ provider.exec("nonexistent", ["echo"])
509
+ ).rejects.toThrow("node not found");
510
+ });
511
+ });
512
+
513
+ describe("destroy", () => {
514
+ it("removes working directory", async () => {
515
+ setupExec("", 0);
516
+ const node = await provider.provision({
517
+ runtime: { image: "any" },
518
+ });
519
+
520
+ setupExec("", 0);
521
+ await provider.destroy(node.externalId);
522
+
523
+ // Check that rm -rf was called
524
+ const rmCmd = mocks.mockExec.mock.calls.find((c: string[]) =>
525
+ c[0].includes("rm -rf")
526
+ );
527
+ expect(rmCmd).toBeDefined();
528
+ });
529
+
530
+ it("is idempotent for already destroyed nodes", async () => {
531
+ await expect(
532
+ provider.destroy("nonexistent")
533
+ ).resolves.toBeUndefined();
534
+ });
535
+
536
+ it("removes node from internal state", async () => {
537
+ setupExec("", 0);
538
+ const node = await provider.provision({
539
+ runtime: { image: "any" },
540
+ });
541
+
542
+ setupExec("", 0);
543
+ await provider.destroy(node.externalId);
544
+
545
+ const status = await provider.inspect(node.externalId);
546
+ expect(status.status).toBe("terminated");
547
+ });
548
+ });
549
+
550
+ describe("inspect", () => {
551
+ it("returns running for active node", async () => {
552
+ setupExec("", 0);
553
+ const node = await provider.provision({
554
+ runtime: { image: "any" },
555
+ });
556
+
557
+ setupExec("ok\n", 0);
558
+ const status = await provider.inspect(node.externalId);
559
+ expect(status.status).toBe("running");
560
+ expect(status.startedAt).toBeInstanceOf(Date);
561
+ });
562
+
563
+ it("returns terminated for unknown node", async () => {
564
+ const status = await provider.inspect("nonexistent");
565
+ expect(status.status).toBe("terminated");
566
+ });
567
+
568
+ it("returns running/stopped based on PID check", async () => {
569
+ // Provision with a command so we get a PID
570
+ let callIdx = 0;
571
+ mocks.mockExec.mockImplementation((cmd: string, cb: Function) => {
572
+ const stdout = callIdx === 1 ? "99\n" : "";
573
+ callIdx++;
574
+ const stream = {
575
+ on: vi.fn().mockImplementation(function (
576
+ this: any,
577
+ event: string,
578
+ handler: Function
579
+ ) {
580
+ if (event === "data") {
581
+ setTimeout(() => handler(Buffer.from(stdout)), 0);
582
+ }
583
+ if (event === "close") {
584
+ setTimeout(() => handler(0), 5);
585
+ }
586
+ return this;
587
+ }),
588
+ stderr: { on: vi.fn().mockReturnThis() },
589
+ };
590
+ cb(undefined, stream);
591
+ });
592
+
593
+ const node = await provider.provision({
594
+ runtime: { image: "any", command: ["sleep", "100"] },
595
+ });
596
+
597
+ // Inspect - process running
598
+ setupExec("running\n", 0);
599
+ const status = await provider.inspect(node.externalId);
600
+ expect(status.status).toBe("running");
601
+
602
+ // Inspect - process stopped
603
+ setupExec("stopped\n", 0);
604
+ const status2 = await provider.inspect(node.externalId);
605
+ expect(status2.status).toBe("stopped");
606
+ });
607
+ });
608
+
609
+ describe("stop / start", () => {
610
+ it("stop kills the managed process", async () => {
611
+ // Provision with PID
612
+ let callIdx = 0;
613
+ mocks.mockExec.mockImplementation((_cmd: string, cb: Function) => {
614
+ const stdout = callIdx === 1 ? "42\n" : "";
615
+ callIdx++;
616
+ const stream = {
617
+ on: vi.fn().mockImplementation(function (
618
+ this: any,
619
+ event: string,
620
+ handler: Function
621
+ ) {
622
+ if (event === "data") {
623
+ setTimeout(() => handler(Buffer.from(stdout)), 0);
624
+ }
625
+ if (event === "close") {
626
+ setTimeout(() => handler(0), 5);
627
+ }
628
+ return this;
629
+ }),
630
+ stderr: { on: vi.fn().mockReturnThis() },
631
+ };
632
+ cb(undefined, stream);
633
+ });
634
+
635
+ const node = await provider.provision({
636
+ runtime: { image: "any", command: ["sleep", "100"] },
637
+ });
638
+
639
+ setupExec("", 0);
640
+ await provider.stop(node.externalId);
641
+
642
+ const killCmd = mocks.mockExec.mock.calls.find((c: string[]) =>
643
+ c[0].includes("kill 42")
644
+ );
645
+ expect(killCmd).toBeDefined();
646
+ });
647
+
648
+ it("start verifies connectivity", async () => {
649
+ setupExec("", 0);
650
+ const node = await provider.provision({
651
+ runtime: { image: "any" },
652
+ });
653
+
654
+ setupExec("ok\n", 0);
655
+ await expect(
656
+ provider.start(node.externalId)
657
+ ).resolves.toBeUndefined();
658
+ });
659
+ });
660
+
661
+ describe("healthCheck", () => {
662
+ it("returns healthy when all hosts reachable", async () => {
663
+ setupExec("ok\n", 0);
664
+ const health = await provider.healthCheck();
665
+
666
+ expect(health.healthy).toBe(true);
667
+ expect(health.latencyMs).toBeTypeOf("number");
668
+ });
669
+
670
+ it("returns unhealthy when host connection fails", async () => {
671
+ mocks.mockOn.mockImplementation(function (
672
+ this: any,
673
+ event: string,
674
+ cb: Function
675
+ ) {
676
+ if (event === "error") {
677
+ setTimeout(() => cb(new Error("connection refused")), 0);
678
+ }
679
+ return this;
680
+ });
681
+
682
+ // Need a fresh provider to trigger new connection
683
+ const freshProvider = new SSHProvider({
684
+ hosts: [{ host: "unreachable" }],
685
+ });
686
+
687
+ const health = await freshProvider.healthCheck();
688
+ expect(health.healthy).toBe(false);
689
+ expect(health.message).toContain("unreachable");
690
+ });
691
+ });
692
+
693
+ describe("getExtension", () => {
694
+ it("returns file extension", async () => {
695
+ setupExec("", 0);
696
+ const node = await provider.provision({
697
+ runtime: { image: "any" },
698
+ });
699
+
700
+ const ext = provider.getExtension(node.externalId, "files");
701
+ expect(ext).toBeDefined();
702
+ expect(ext).toHaveProperty("read");
703
+ expect(ext).toHaveProperty("write");
704
+ expect(ext).toHaveProperty("list");
705
+ expect(ext).toHaveProperty("remove");
706
+ expect(ext).toHaveProperty("mkdir");
707
+ });
708
+
709
+ it("returns network extension", async () => {
710
+ setupExec("", 0);
711
+ const node = await provider.provision({
712
+ runtime: { image: "any" },
713
+ });
714
+
715
+ const ext = provider.getExtension(node.externalId, "network");
716
+ expect(ext).toBeDefined();
717
+ expect(ext!.getUrl).toBeTypeOf("function");
718
+ });
719
+
720
+ it("network extension returns host URL", async () => {
721
+ setupExec("", 0);
722
+ const node = await provider.provision({
723
+ runtime: { image: "any" },
724
+ });
725
+
726
+ const ext = provider.getExtension(node.externalId, "network");
727
+ const url = await ext!.getUrl(8080);
728
+ expect(url).toBe("http://192.168.1.100:8080");
729
+ });
730
+
731
+ it("returns undefined for unsupported extensions", async () => {
732
+ setupExec("", 0);
733
+ const node = await provider.provision({
734
+ runtime: { image: "any" },
735
+ });
736
+
737
+ expect(
738
+ provider.getExtension(node.externalId, "metrics")
739
+ ).toBeUndefined();
740
+ });
741
+ });
742
+
743
+ describe("close", () => {
744
+ it("ends all connections", async () => {
745
+ setupExec("", 0);
746
+ await provider.provision({
747
+ runtime: { image: "any" },
748
+ });
749
+
750
+ await provider.close();
751
+ expect(mocks.mockEnd).toHaveBeenCalled();
752
+ });
753
+ });
754
+
755
+ describe("host selection", () => {
756
+ it("round-robins across hosts", async () => {
757
+ const multiProvider = new SSHProvider({
758
+ hosts: [
759
+ { host: "host-a" },
760
+ { host: "host-b" },
761
+ ],
762
+ });
763
+
764
+ setupExec("", 0);
765
+ const n1 = await multiProvider.provision({
766
+ runtime: { image: "any" },
767
+ });
768
+
769
+ setupExec("", 0);
770
+ const n2 = await multiProvider.provision({
771
+ runtime: { image: "any" },
772
+ });
773
+
774
+ expect(n1.externalId).toContain("host-a");
775
+ expect(n2.externalId).toContain("host-b");
776
+ });
777
+ });
778
+ });