Skip to content
Source
Sign inSign up
Open History to see the last commit

SshDeviceHost.test.ts

597 lines22.3 KB

1import { expect, it } from "@effect/vitest";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Net from "@t3tools/shared/Net";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as PlatformError from "effect/PlatformError";
11import * as Sink from "effect/Sink";
12import * as Stream from "effect/Stream";
13import * as HttpClient from "effect/http/HttpClient";
14import * as HttpClientResponse from "effect/http/HttpClientResponse";
15import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
16import * as TestClock from "effect/testing/TestClock";
17import * as ServerConfig from "../config.ts";
18import * as DeviceHost from "./DeviceHost.ts";
19import * as SshDeviceHost from "./SshDeviceHost.ts";
20​
21it.effect("preserves installed status after probes and cleans failed agent activation", () =>
22 Effect.gen(function* () {
23 const fs = yield* FileSystem.FileSystem;
24 const home = yield* fs.makeTempDirectoryScoped();
25 const modes: string[] = [];
26 const owners: string[] = [];
27 const tunnelArgs: string[][] = [];
28 const tunnelStdins: unknown[] = [];
29 const commandArgs: string[][] = [];
30 let forwards = 0;
31 let failForward = true;
32 let rejectConfig = true;
33 const releases: Array<string | undefined> = [];
34 const spawner = ChildProcessSpawner.make((command) =>
35 Effect.gen(function* () {
36 if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command");
37 const forwarding = command.args.includes("-L");
38 (forwarding ? tunnelArgs : commandArgs).push(command.args.map(String));
39 if (forwarding) tunnelStdins.push(command.options.stdin);
40 let output = "";
41 let exitCode = 0;
42 if (forwarding) {
43 if (failForward) {
44 failForward = false;
45 return yield* PlatformError.systemError({
46 _tag: "AlreadyExists",
47 module: "ChildProcess",
48 method: "spawn",
49 description: "Port already bound",
50 });
51 }
52 forwards++;
53 yield* Effect.addFinalizer(() =>
54 Effect.sync(() => {
55 forwards--;
56 }),
57 );
58 } else {
59 const stdin = command.options.stdin;
60 if (
61 !stdin ||
62 typeof stdin !== "object" ||
63 !("stream" in stdin) ||
64 !Stream.isStream(stdin.stream)
65 )
66 return yield* Effect.die("Missing script");
67 const script = yield* stdin.stream.pipe(
68 Stream.decodeText(),
69 Stream.runFold(
70 () => "",
71 (a, b) => a + b,
72 ),
73 );
74 const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? "";
75 modes.push(mode);
76 owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? "");
77 output = JSON.stringify({
78 nodePath: "/node",
79 platforms: [{ platform: "ios", available: true }],
80 hubPort: 1234,
81 helpers: { serveSimAxSettings: null, serveSimCli: null },
82 ...(mode === "agent-start"
83 ? { daemonPort: 1235, token: "fixture", entryPath: "/agent.mjs" }
84 : {}),
85 });
86 if (mode === "") {
87 output = `${"verbose-prefix ".repeat(400)}PANIC: emulator SDK is broken\n`;
88 exitCode = 1;
89 }
90 }
91 return ChildProcessSpawner.makeHandle({
92 pid: ChildProcessSpawner.ProcessId(123),
93 stdout: Stream.make(new TextEncoder().encode(output)),
94 stderr: Stream.empty,
95 all: Stream.empty,
96 exitCode: forwarding
97 ? Effect.never
98 : Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
99 isRunning: Effect.succeed(forwarding),
100 kill: () => Effect.void,
101 stdin: Sink.drain,
102 getInputFd: () => Sink.drain,
103 getOutputFd: () => Stream.empty,
104 unref: Effect.succeed(Effect.void),
105 });
106 }),
107 );
108 const host = yield* SshDeviceHost.make(
109 { kind: "ssh", id: "test", label: "Test", target: "test.example" },
110 () =>
111 rejectConfig
112 ? Effect.fail(
113 new DeviceHost.DeviceHostError({
114 hostId: "test",
115 step: "configuring agent access",
116 cause: new Error("fixture failure"),
117 }),
118 )
119 : Effect.void,
120 ).pipe(
121 Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)),
122 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
123 Effect.provideService(
124 HttpClient.HttpClient,
125 HttpClient.make((request) =>
126 Effect.sync(() => {
127 if (!request.url.endsWith("/rpc"))
128 return HttpClientResponse.fromWeb(request, new Response("ok"));
129 releases.push(request.headers.authorization);
130 return HttpClientResponse.fromWeb(request, Response.json({ result: { ok: true } }));
131 }),
132 ),
133 ),
134 );
135 yield* host.ensureReady(() => Effect.void);
136 yield* SshDeviceHost.probe({
137 kind: "ssh",
138 id: "test",
139 label: "Test",
140 target: "test.example",
141 }).pipe(
142 Effect.provide(ServerConfig.layerTest(home, home)),
143 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
144 );
145 expect(new Set(owners).size).toBe(1);
146 expect(owners[0]).toMatch(/^[a-f0-9]{24}$/);
147 expect(forwards).toBe(1);
148 expect(modes.filter((mode) => mode === "start")).toHaveLength(2);
149 const ready = yield* host.current;
150 expect(ready).not.toBeNull();
151 if (ready) {
152 const failed = yield* ready.run("emulator", ["-list-avds"]);
153 expect(failed.code).toBe(1);
154 expect(failed.stdout).toMatch(/^\[truncated\]/);
155 expect(failed.stderr).toBe(failed.stdout.trim());
156 expect(failed.stderr).toMatch(/PANIC: emulator SDK is broken$/);
157 }
158 yield* host.platformAvailability("ios");
159 expect((yield* host.summary).hubInstalled).toBe(true);
160 const failed = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.result);
161 expect(failed._tag).toBe("Failure");
162 expect(forwards).toBe(0);
163 expect(modes.at(-1)).toBe("stop-agent");
164 expect(yield* host.current).toBeNull();
165 rejectConfig = false;
166 yield* host.ensureAgentReady(() => Effect.void);
167 yield* host.platformAvailability("ios");
168 expect((yield* host.summary).agentDeviceInstalled).toBe(true);
169 // Close releases agent sessions through the forwarded daemon.
170 const agentReady = yield* host.current;
171 yield* agentReady!.releaseAgentSession!("t3-session", "device");
172 expect(releases).toEqual(["Bearer fixture"]);
173 yield* host.stopAgent;
174 expect(forwards).toBe(1);
175 const multiplexingOff = ["ControlMaster=no", "ControlPath=none", "ControlPersist=no"];
176 expect(tunnelArgs.length).toBeGreaterThan(0);
177 for (const args of tunnelArgs) {
178 for (const option of multiplexingOff) {
179 const index = args.indexOf(option);
180 expect(index).toBeGreaterThan(0);
181 expect(args[index - 1]).toBe("-o");
182 }
183 // The tunnel lives only as long as the server holds the write end of its stdin.
184 expect(args).not.toContain("-N");
185 expect(args.slice(args.indexOf("test.example") + 1)).toEqual([
186 "sh",
187 "-c",
188 "'exec cat >/dev/null'",
189 ]);
190 }
191 expect(tunnelStdins).toEqual(tunnelArgs.map(() => "pipe"));
192 expect(commandArgs.length).toBeGreaterThan(0);
193 for (const args of commandArgs) {
194 for (const option of multiplexingOff) expect(args).not.toContain(option);
195 }
196 yield* host.stop;
197 expect(forwards).toBe(0);
198 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
199);
200​
201it.effect("releases the forward of a bootstrap interrupted before its hub answered", () =>
202 Effect.gen(function* () {
203 const fs = yield* FileSystem.FileSystem;
204 const home = yield* fs.makeTempDirectoryScoped();
205 const probing = yield* Deferred.make<void>();
206 let holdReadiness = true;
207 let forwards = 0;
208 const spawner = ChildProcessSpawner.make((command) =>
209 Effect.gen(function* () {
210 const forwarding = command._tag === "StandardCommand" && command.args.includes("-L");
211 if (forwarding) {
212 forwards++;
213 yield* Effect.addFinalizer(() =>
214 Effect.sync(() => {
215 forwards--;
216 }),
217 );
218 }
219 const output = forwarding
220 ? ""
221 : JSON.stringify({
222 nodePath: "/node",
223 platforms: [{ platform: "ios", available: true }],
224 hubPort: 1234,
225 helpers: { serveSimAxSettings: null, serveSimCli: null },
226 });
227 return ChildProcessSpawner.makeHandle({
228 pid: ChildProcessSpawner.ProcessId(123),
229 stdout: Stream.make(new TextEncoder().encode(output)),
230 stderr: Stream.empty,
231 all: Stream.empty,
232 exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)),
233 isRunning: Effect.succeed(forwarding),
234 kill: () => Effect.void,
235 stdin: Sink.drain,
236 getInputFd: () => Sink.drain,
237 getOutputFd: () => Stream.empty,
238 unref: Effect.succeed(Effect.void),
239 });
240 }),
241 );
242 const publishing = yield* Deferred.make<void>();
243 let holdReadyStatus = false;
244 const host = yield* SshDeviceHost.make(
245 {
246 kind: "ssh",
247 id: "test",
248 label: "Test",
249 target: "test.example",
250 },
251 undefined,
252 (status) =>
253 status === "ready" && holdReadyStatus
254 ? Deferred.succeed(publishing, undefined).pipe(Effect.andThen(Effect.never))
255 : Effect.void,
256 ).pipe(
257 Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)),
258 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
259 Effect.provideService(
260 HttpClient.HttpClient,
261 HttpClient.make((request) =>
262 Effect.gen(function* () {
263 if (holdReadiness) {
264 yield* Deferred.succeed(probing, undefined);
265 return yield* Effect.never;
266 }
267 return HttpClientResponse.fromWeb(request, new Response("ok"));
268 }),
269 ),
270 ),
271 );
272 const first = yield* host.ensureReady(() => Effect.void).pipe(Effect.forkChild);
273 yield* Deferred.await(probing);
274 yield* Fiber.interrupt(first);
275 expect(forwards).toBe(0);
276 holdReadiness = false;
277 yield* host.ensureReady(() => Effect.void);
278 yield* host.stop;
279 expect(forwards).toBe(0);
280 holdReadyStatus = true;
281 const second = yield* host.ensureReady(() => Effect.void).pipe(Effect.forkChild);
282 yield* Deferred.await(publishing);
283 yield* Fiber.interrupt(second);
284 holdReadyStatus = false;
285 yield* host.ensureReady(() => Effect.void);
286 expect(forwards).toBe(1);
287 yield* host.stop;
288 expect(forwards).toBe(0);
289 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
290);
291​
292it.effect("lists Android devices without probing the host again once it reports Android", () =>
293 Effect.gen(function* () {
294 const fs = yield* FileSystem.FileSystem;
295 const home = yield* fs.makeTempDirectoryScoped();
296 const modes: string[] = [];
297 let androidInstalled = false;
298 const spawner = ChildProcessSpawner.make((command) =>
299 Effect.gen(function* () {
300 if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command");
301 const forwarding = command.args.includes("-L");
302 let output = "";
303 if (!forwarding) {
304 const stdin = command.options.stdin;
305 const script =
306 stdin && typeof stdin === "object" && "stream" in stdin && Stream.isStream(stdin.stream)
307 ? yield* stdin.stream.pipe(
308 Stream.decodeText(),
309 Stream.runFold(
310 () => "",
311 (a, b) => a + b,
312 ),
313 )
314 : "";
315 const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? "";
316 modes.push(mode);
317 output =
318 mode === ""
319 ? "Pixel_8\n"
320 : JSON.stringify({
321 nodePath: "/node",
322 platforms: [
323 { platform: "ios", available: true },
324 { platform: "android", available: androidInstalled },
325 ],
326 hubPort: 1234,
327 helpers: { serveSimAxSettings: null, serveSimCli: null },
328 });
329 }
330 return ChildProcessSpawner.makeHandle({
331 pid: ChildProcessSpawner.ProcessId(123),
332 stdout: Stream.make(new TextEncoder().encode(output)),
333 stderr: Stream.empty,
334 all: Stream.empty,
335 exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)),
336 isRunning: Effect.succeed(forwarding),
337 kill: () => Effect.void,
338 stdin: Sink.drain,
339 getInputFd: () => Sink.drain,
340 getOutputFd: () => Stream.empty,
341 unref: Effect.succeed(Effect.void),
342 });
343 }),
344 );
345 const host = yield* SshDeviceHost.make({
346 kind: "ssh",
347 id: "test",
348 label: "Test",
349 target: "test.example",
350 }).pipe(
351 Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)),
352 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
353 Effect.provideService(
354 HttpClient.HttpClient,
355 HttpClient.make((request) =>
356 Effect.succeed(
357 HttpClientResponse.fromWeb(
358 request,
359 new Response(
360 request.url.endsWith("/api/devices")
361 ? JSON.stringify({ simulators: [], emulators: [] })
362 : "ok",
363 ),
364 ),
365 ),
366 ),
367 ),
368 );
369 const ready = yield* host.ensureReady(() => Effect.void);
370 const androidNames = ready.devices.list.pipe(
371 Effect.map((list) =>
372 list.devices.filter((device) => device.platform === "android").map((device) => device.name),
373 ),
374 );
375​
376 modes.splice(0);
377​
378 expect(yield* androidNames).toEqual([]);
379 expect(modes.splice(0)).toEqual(["probe"]);
380 expect(yield* androidNames).toEqual([]);
381 expect(modes.splice(0)).toEqual(["probe"]);
382​
383 // The SDK appears after connecting, so the next listing still probes the host.
384 androidInstalled = true;
385 expect(yield* androidNames).toEqual(["Pixel_8"]);
386 expect(modes.splice(0)).toEqual(["probe", ""]);
387​
388 // Android is now known to be available, so listing only runs `emulator -list-avds`.
389 expect(yield* androidNames).toEqual(["Pixel_8"]);
390 expect(modes.splice(0)).toEqual([""]);
391​
392 androidInstalled = false;
393 expect((yield* host.platformAvailability("android")).available).toBe(false);
394 expect(modes.splice(0)).toEqual(["probe"]);
395 expect(yield* androidNames).toEqual([]);
396 expect(modes.splice(0)).toEqual(["probe"]);
397​
398 androidInstalled = true;
399 yield* host.inspect;
400 expect(modes.splice(0)).toEqual(["probe"]);
401 expect(yield* androidNames).toEqual(["Pixel_8"]);
402 expect(modes.splice(0)).toEqual([""]);
403 yield* host.stop;
404 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
405);
406​
407it.effect(
408 "keeps the SSH forward through slow health probes and reconnects once the hub stays down",
409 () =>
410 Effect.gen(function* () {
411 const fs = yield* FileSystem.FileSystem;
412 const home = yield* fs.makeTempDirectoryScoped();
413 let forwardSpawns = 0;
414 let hubAnswers = true;
415 const spawner = ChildProcessSpawner.make((command) =>
416 Effect.gen(function* () {
417 if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command");
418 const forwarding = command.args.includes("-L");
419 // A fresh forward reaches a working hub again.
420 if (forwarding && ++forwardSpawns > 1) hubAnswers = true;
421 const output = JSON.stringify({
422 nodePath: "/node",
423 platforms: [{ platform: "ios", available: true }],
424 hubPort: 1234,
425 helpers: { serveSimAxSettings: null, serveSimCli: null },
426 });
427 return ChildProcessSpawner.makeHandle({
428 pid: ChildProcessSpawner.ProcessId(123),
429 stdout: Stream.make(new TextEncoder().encode(output)),
430 stderr: Stream.empty,
431 all: Stream.empty,
432 exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)),
433 isRunning: Effect.succeed(forwarding),
434 kill: () => Effect.void,
435 stdin: Sink.drain,
436 getInputFd: () => Sink.drain,
437 getOutputFd: () => Stream.empty,
438 unref: Effect.succeed(Effect.void),
439 });
440 }),
441 );
442 const host = yield* SshDeviceHost.make({
443 kind: "ssh",
444 id: "test",
445 label: "Test",
446 target: "test.example",
447 }).pipe(
448 Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)),
449 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
450 Effect.provideService(
451 HttpClient.HttpClient,
452 HttpClient.make((request) =>
453 hubAnswers
454 ? Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok")))
455 : Effect.never,
456 ),
457 ),
458 );
459 yield* host.ensureReady(() => Effect.void);
460 expect(forwardSpawns).toBe(1);
461​
462 // A loaded host can miss a probe or two without being gone; the forward stays up.
463 hubAnswers = false;
464 yield* TestClock.adjust("30 seconds");
465 hubAnswers = true;
466 yield* TestClock.adjust("2 minutes");
467 expect(forwardSpawns).toBe(1);
468​
469 // A hub that keeps missing probes is still replaced.
470 hubAnswers = false;
471 // Recovery reset the miss count, so two new misses do not reconnect yet.
472 yield* TestClock.adjust("30 seconds");
473 expect(forwardSpawns).toBe(1);
474 yield* TestClock.adjust("30 seconds");
475 expect(forwardSpawns).toBe(2);
476 yield* host.stop;
477 }).pipe(
478 Effect.scoped,
479 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, TestClock.layer())),
480 ),
481);
482​
483it.effect.each([
484 {
485 name: "a Windows PowerShell login shell",
486 exitCode: 1,
487 stderr: "sh : The term 'sh' is not recognized as the name of a cmdlet\r\n",
488 shellEcho: "%OS%\r\nWindows_NT\r\n",
489 nodePlatform: null,
490 windows: true,
491 },
492 {
493 name: "a Windows cmd login shell",
494 exitCode: 1,
495 stderr: "'sh' is not recognized as an internal or external command,\r\n",
496 shellEcho: "Windows_NT $env:OS\r\n",
497 nodePlatform: null,
498 windows: true,
499 },
500 {
501 name: "a Windows cmd login shell returning 9009",
502 exitCode: 9009,
503 stderr: "'sh' is not recognized as an internal or external command,\r\n",
504 shellEcho: "Windows_NT $env:OS\r\n",
505 nodePlatform: null,
506 windows: true,
507 },
508 {
509 name: "a Windows Git Bash login shell without a spawnable npm",
510 exitCode: 1,
511 stderr: "npm is missing from the non-interactive SSH PATH\n",
512 shellEcho: "%OS% :OS\n",
513 nodePlatform: "win32",
514 windows: true,
515 },
516 {
517 name: "a Linux host without npm",
518 exitCode: 1,
519 stderr: "npm is missing from the non-interactive SSH PATH\n",
520 shellEcho: "%OS% :OS\n",
521 nodePlatform: "linux",
522 windows: false,
523 },
524 {
525 name: "a POSIX host without Node",
526 exitCode: 1,
527 stderr: "Node is missing from the non-interactive SSH PATH\n",
528 shellEcho: "%OS% :OS\n",
529 nodePlatform: null,
530 windows: false,
531 },
532 {
533 name: "a connection failure",
534 exitCode: 255,
535 stderr: "Connection refused\n",
536 shellEcho: "Windows_NT\n",
537 nodePlatform: "win32",
538 windows: false,
539 },
540])(
541 "probe failure on $name keeps the SSH cause",
542 ({ exitCode, stderr, shellEcho, nodePlatform, windows }) =>
543 Effect.gen(function* () {
544 const fs = yield* FileSystem.FileSystem;
545 const home = yield* fs.makeTempDirectoryScoped();
546 const shellChecks: string[] = [];
547 const spawner = ChildProcessSpawner.make((command) =>
548 Effect.gen(function* () {
549 if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command");
550 const remote = command.args.slice(command.args.indexOf("test.example") + 1).join(" ");
551 const shellCheck = remote === "echo %OS% $env:OS";
552 const platformCheck = remote === "node -p process.platform";
553 if (shellCheck || platformCheck) shellChecks.push(remote);
554 return ChildProcessSpawner.makeHandle({
555 pid: ChildProcessSpawner.ProcessId(123),
556 stdout: Stream.make(
557 new TextEncoder().encode(
558 shellCheck ? shellEcho : platformCheck ? `${nodePlatform ?? ""}\n` : "",
559 ),
560 ),
561 stderr: Stream.make(
562 new TextEncoder().encode(shellCheck || platformCheck ? "" : stderr),
563 ),
564 all: Stream.empty,
565 exitCode: Effect.succeed(
566 ChildProcessSpawner.ExitCode(
567 shellCheck ? 0 : platformCheck ? (nodePlatform === null ? 1 : 0) : exitCode,
568 ),
569 ),
570 isRunning: Effect.succeed(false),
571 kill: () => Effect.void,
572 stdin: Sink.drain,
573 getInputFd: () => Sink.drain,
574 getOutputFd: () => Stream.empty,
575 unref: Effect.succeed(Effect.void),
576 });
577 }),
578 );
579 const failure = yield* SshDeviceHost.probe({
580 kind: "ssh",
581 id: "test",
582 label: "Test",
583 target: "test.example",
584 }).pipe(
585 Effect.provide(ServerConfig.layerTest(home, home)),
586 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
587 Effect.flip,
588 );
589 expect(failure).toMatchObject({ _tag: "DeviceHostError", step: "probe" });
590 const sshFailure = { _tag: "SshCommandError", exitCode, stderr };
591 expect(failure.cause).toMatchObject(
592 windows ? { _tag: "SshWindowsHostError", cause: sshFailure } : sshFailure,
593 );
594 // A transport failure has no remote shell to ask.
595 if (exitCode === 255) expect(shellChecks).toEqual([]);
596 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
597);