| 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Sink from "effect/Sink"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { ChildProcessSpawner } from "effect/process"; |
| 7 | |
| 8 | import { |
| 9 | type DevShareError, |
| 10 | DevServeFailedError, |
| 11 | shareDevServer, |
| 12 | unshareDevServer, |
| 13 | } from "./dev-share.ts"; |
| 14 | |
| 15 | const TAILNET_STATUS = JSON.stringify({ Self: { DNSName: "host.example.ts.net." } }); |
| 16 | const NO_HANDLER_STDERR = "error: failed to remove web serve: handler does not exist"; |
| 17 | |
| 18 | interface CallResult { |
| 19 | readonly exitCode: number; |
| 20 | readonly stderr?: string; |
| 21 | } |
| 22 | |
| 23 | const encode = (value: string) => Stream.make(new TextEncoder().encode(value)); |
| 24 | |
| 25 | /** |
| 26 | * Answers `tailscale status --json` with a valid tailnet name, and lets each |
| 27 | * test set the outcome of the `off` (pre-clear) and `serve` calls separately — |
| 28 | * they are the same subcommand and are told apart by the trailing `off`. |
| 29 | */ |
| 30 | const spawnerLayer = (input: { |
| 31 | readonly off?: CallResult; |
| 32 | readonly serve?: CallResult; |
| 33 | readonly calls?: Array<ReadonlyArray<string>>; |
| 34 | }) => |
| 35 | Layer.succeed( |
| 36 | ChildProcessSpawner.ChildProcessSpawner, |
| 37 | ChildProcessSpawner.make((command) => { |
| 38 | const args = "args" in command ? (command.args as ReadonlyArray<string>) : []; |
| 39 | input.calls?.push(args); |
| 40 | const result: CallResult = args.includes("status") |
| 41 | ? { exitCode: 0 } |
| 42 | : args.includes("off") |
| 43 | ? (input.off ?? { exitCode: 0 }) |
| 44 | : (input.serve ?? { exitCode: 0 }); |
| 45 | |
| 46 | return Effect.succeed( |
| 47 | ChildProcessSpawner.makeHandle({ |
| 48 | pid: ChildProcessSpawner.ProcessId(1), |
| 49 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), |
| 50 | isRunning: Effect.succeed(false), |
| 51 | kill: () => Effect.void, |
| 52 | unref: Effect.succeed(Effect.void), |
| 53 | stdin: Sink.drain, |
| 54 | stdout: args.includes("status") ? encode(TAILNET_STATUS) : Stream.empty, |
| 55 | stderr: result.stderr ? encode(result.stderr) : Stream.empty, |
| 56 | all: Stream.empty, |
| 57 | getInputFd: () => Sink.drain, |
| 58 | getOutputFd: () => Stream.empty, |
| 59 | }), |
| 60 | ); |
| 61 | }), |
| 62 | ); |
| 63 | |
| 64 | describe("unshareDevServer", () => { |
| 65 | it.effect("treats a removed mapping as cleared", () => |
| 66 | Effect.gen(function* () { |
| 67 | const result = yield* unshareDevServer(5788).pipe( |
| 68 | Effect.provide(spawnerLayer({ off: { exitCode: 0 } })), |
| 69 | ); |
| 70 | assert.isTrue(result.cleared); |
| 71 | }), |
| 72 | ); |
| 73 | |
| 74 | // `tailscale serve … off` exits 1 when the port had no mapping, which is the |
| 75 | // normal first-share case — the port is clear, so this must not be an error. |
| 76 | it.effect("treats a missing handler as cleared", () => |
| 77 | Effect.gen(function* () { |
| 78 | const result = yield* unshareDevServer(5788).pipe( |
| 79 | Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: NO_HANDLER_STDERR } })), |
| 80 | ); |
| 81 | assert.isTrue(result.cleared); |
| 82 | }), |
| 83 | ); |
| 84 | |
| 85 | it.effect("reports a genuine removal failure as not cleared", () => |
| 86 | Effect.gen(function* () { |
| 87 | const result = yield* unshareDevServer(5788).pipe( |
| 88 | Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })), |
| 89 | ); |
| 90 | assert.isFalse(result.cleared); |
| 91 | assert.include(result.explanation, "permission denied"); |
| 92 | // Structured, so a wrapping error can keep the real chain. |
| 93 | assert.equal(result.cause?._tag, "TailscaleCommandExitError"); |
| 94 | }), |
| 95 | ); |
| 96 | }); |
| 97 | |
| 98 | describe("shareDevServer", () => { |
| 99 | it.effect("returns the tailnet URL for the same port", () => |
| 100 | Effect.gen(function* () { |
| 101 | const shared = yield* shareDevServer({ webPort: 5788 }).pipe( |
| 102 | Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: NO_HANDLER_STDERR } })), |
| 103 | ); |
| 104 | |
| 105 | assert.equal(shared.host, "host.example.ts.net"); |
| 106 | assert.equal(shared.url, "https://host.example.ts.net:5788/"); |
| 107 | }), |
| 108 | ); |
| 109 | |
| 110 | // Vite binds `localhost`, which modern Node resolves to `::1` first, so a |
| 111 | // 127.0.0.1 target would proxy to a loopback nothing listens on. |
| 112 | it.effect("proxies to the localhost name Vite binds, not 127.0.0.1", () => |
| 113 | Effect.gen(function* () { |
| 114 | const calls: Array<ReadonlyArray<string>> = []; |
| 115 | yield* shareDevServer({ webPort: 5788 }).pipe( |
| 116 | Effect.provide(spawnerLayer({ off: { exitCode: 0 }, calls })), |
| 117 | ); |
| 118 | |
| 119 | const serveCall = calls.find((args) => args.includes("--bg")); |
| 120 | assert.deepEqual(serveCall, ["serve", "--bg", "--https=5788", "http://localhost:5788"]); |
| 121 | }), |
| 122 | ); |
| 123 | |
| 124 | // The stale-mapping clear runs before serve, so a failure here leaves the |
| 125 | // port serving nothing. Saying only "serve failed" would let an operator |
| 126 | // assume their previous mapping survived. |
| 127 | it.effect("reports that the prior mapping was cleared when serve fails", () => |
| 128 | Effect.gen(function* () { |
| 129 | const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( |
| 130 | Effect.provide( |
| 131 | spawnerLayer({ |
| 132 | off: { exitCode: 0 }, |
| 133 | serve: { exitCode: 1, stderr: "port already in use" }, |
| 134 | }), |
| 135 | ), |
| 136 | Effect.flip, |
| 137 | ); |
| 138 | |
| 139 | assert.instanceOf(error, DevServeFailedError); |
| 140 | assert.equal(error.stage, "serve"); |
| 141 | assert.equal(error.webPort, 5788); |
| 142 | // The underlying failure is preserved rather than flattened to a string. |
| 143 | assert.equal( |
| 144 | (error.cause as { _tag?: string } | undefined)?._tag, |
| 145 | "TailscaleCommandExitError", |
| 146 | ); |
| 147 | // Unclassifiable stderr is never quoted (it can carry auth keys), so the |
| 148 | // message points at the command instead of echoing the CLI. |
| 149 | assert.notInclude(error.message, "port already in use"); |
| 150 | assert.include(error.message, "run the command by hand"); |
| 151 | assert.include(error.message, "no longer served"); |
| 152 | assert.include(error.message, "5788"); |
| 153 | }), |
| 154 | ); |
| 155 | |
| 156 | // A recognized failure gets our own wording for it — enough to act on |
| 157 | // without passing CLI text through. |
| 158 | it.effect("explains a recognized serve failure without quoting stderr", () => |
| 159 | Effect.gen(function* () { |
| 160 | const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( |
| 161 | Effect.provide( |
| 162 | spawnerLayer({ |
| 163 | off: { exitCode: 0 }, |
| 164 | serve: { |
| 165 | exitCode: 1, |
| 166 | stderr: "permission denied for tskey-auth-secret-token-value", |
| 167 | }, |
| 168 | }), |
| 169 | ), |
| 170 | Effect.flip, |
| 171 | ); |
| 172 | |
| 173 | assert.instanceOf(error, DevServeFailedError); |
| 174 | assert.equal(error.stage, "serve"); |
| 175 | assert.include(error.message, "permission denied"); |
| 176 | assert.include(error.message, "elevated privileges"); |
| 177 | assert.notInclude(error.message, "tskey-auth-secret-token-value"); |
| 178 | }), |
| 179 | ); |
| 180 | |
| 181 | // Serving over routes we could not remove yields a URL that loads but whose |
| 182 | // /ws and /api quietly point at a dead backend. |
| 183 | it.effect("refuses to serve when the existing mapping could not be cleared", () => |
| 184 | Effect.gen(function* () { |
| 185 | const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( |
| 186 | Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })), |
| 187 | Effect.flip, |
| 188 | ); |
| 189 | |
| 190 | assert.instanceOf(error, DevServeFailedError); |
| 191 | // A distinct stage: the prior mapping survived, so nothing was replaced. |
| 192 | assert.equal(error.stage, "clear-existing"); |
| 193 | assert.include(error.message, "could not clear the existing mapping"); |
| 194 | assert.include(error.message, "permission denied"); |
| 195 | }), |
| 196 | ); |
| 197 | }); |