1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";
6import { ChildProcessSpawner } from "effect/process";
7
8import {
9 type DevShareError,
10 DevServeFailedError,
11 shareDevServer,
12 unshareDevServer,
13} from "./dev-share.ts";
14
15const TAILNET_STATUS = JSON.stringify({ Self: { DNSName: "host.example.ts.net." } });
16const NO_HANDLER_STDERR = "error: failed to remove web serve: handler does not exist";
17
18interface CallResult {
19 readonly exitCode: number;
20 readonly stderr?: string;
21}
22
23const 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 */
30const 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
64describe("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
98describe("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});