Unified · Split
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | +import * as Option from "effect/Option"; | |
| 4 | 5 | import { HttpClient } from "effect/unstable/http"; |
| 5 | 6 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | 7 | |
| @@ -34,8 +35,8 @@ describe("tailscale endpoint provider", () => { · 27 unmodified lines | ||
| 34 | 35 | const dnsName = yield* parseTailscaleMagicDnsName( |
| 35 | 36 | `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, |
| 36 | 37 | ); |
| 37 | − assert.equal(dnsName, "desktop.tail.ts.net"); | |
| 38 | − assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); | |
| 38 | + assert.deepEqual(dnsName, Option.some("desktop.tail.ts.net")); | |
| 39 | + assert.deepEqual(yield* parseTailscaleMagicDnsName("{}"), Option.none()); | |
| 39 | 40 | const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json")); |
| 40 | 41 | assert.isTrue(malformed._tag === "Failure"); |
| 41 | 42 | }), |
| @@ -61,17 +61,17 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { · 60 unmodified lines | ||
| 61 | 61 | const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( |
| 62 | 62 | "resolveTailscaleMagicDnsAdvertisedEndpoint", |
| 63 | 63 | )(function* (input: { |
| 64 | − readonly dnsName: string | null; | |
| 64 | + readonly dnsName: Option.Option<string>; | |
| 65 | 65 | readonly serveEnabled: boolean; |
| 66 | 66 | readonly servePort?: number; |
| 67 | 67 | readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>; |
| 68 | 68 | }): Effect.fn.Return<Option.Option<AdvertisedEndpoint>, never, HttpClient.HttpClient> { |
| 69 | − if (!input.dnsName) { | |
| 69 | + if (Option.isNone(input.dnsName)) { | |
| 70 | 70 | return Option.none(); |
| 71 | 71 | } |
| 72 | 72 | |
| 73 | 73 | const httpBaseUrl = buildTailscaleHttpsBaseUrl({ |
| 74 | − magicDnsName: input.dnsName, | |
| 74 | + magicDnsName: input.dnsName.value, | |
| 75 | 75 | ...(input.servePort === undefined ? {} : { servePort: input.servePort }), |
| 76 | 76 | }); |
| 77 | 77 | const probe = |
| @@ -116,13 +116,13 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd · 38 unmodified lines | ||
| 116 | 116 | input.statusJson === undefined |
| 117 | 117 | ? yield* readTailscaleStatus.pipe( |
| 118 | 118 | Effect.map((status) => status.magicDnsName), |
| 119 | − Effect.catch(() => Effect.succeed(null)), | |
| 119 | + Effect.catch(() => Effect.succeed(Option.none())), | |
| 120 | 120 | ) |
| 121 | 121 | : input.statusJson |
| 122 | 122 | ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( |
| 123 | − Effect.catch(() => Effect.succeed(null)), | |
| 123 | + Effect.catch(() => Effect.succeed(Option.none())), | |
| 124 | 124 | ) |
| 125 | − : null; | |
| 125 | + : Option.none(); | |
| 126 | 126 | const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ |
| 127 | 127 | dnsName, |
| 128 | 128 | serveEnabled: input.serveEnabled === true, |
| @@ -1,8 +1,12 @@ | ||
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | +import * as Duration from "effect/Duration"; | |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 4 | +import * as Fiber from "effect/Fiber"; | |
| 3 | 5 | import * as Layer from "effect/Layer"; |
| 6 | +import * as Option from "effect/Option"; | |
| 4 | 7 | import * as Sink from "effect/Sink"; |
| 5 | 8 | import * as Stream from "effect/Stream"; |
| 9 | +import * as TestClock from "effect/testing/TestClock"; | |
| 6 | 10 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 7 | 11 | |
| 8 | 12 | import { |
| @@ -13,6 +17,7 @@ import { · 4 unmodified lines | ||
| 13 | 17 | parseTailscaleMagicDnsName, |
| 14 | 18 | parseTailscaleStatus, |
| 15 | 19 | readTailscaleStatus, |
| 20 | + TAILSCALE_STATUS_TIMEOUT, | |
| 16 | 21 | } from "./tailscale.ts"; |
| 17 | 22 | |
| 18 | 23 | const encoder = new TextEncoder(); |
| @@ -35,22 +40,37 @@ function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) · 16 unmodified lines | ||
| 35 | 40 | }); |
| 36 | 41 | } |
| 37 | 42 | |
| 43 | +function mockHangingHandle() { | |
| 44 | + return ChildProcessSpawner.makeHandle({ | |
| 45 | + pid: ChildProcessSpawner.ProcessId(1), | |
| 46 | + exitCode: Effect.never, | |
| 47 | + isRunning: Effect.succeed(true), | |
| 48 | + kill: () => Effect.void, | |
| 49 | + unref: Effect.succeed(Effect.void), | |
| 50 | + stdin: Sink.drain, | |
| 51 | + stdout: Stream.empty, | |
| 52 | + stderr: Stream.empty, | |
| 53 | + all: Stream.empty, | |
| 54 | + getInputFd: () => Sink.drain, | |
| 55 | + getOutputFd: () => Stream.empty, | |
| 56 | + }); | |
| 57 | +} | |
| 58 | + | |
| 38 | 59 | function mockSpawnerLayer( |
| 39 | 60 | handler: ( |
| 40 | 61 | command: string, |
| 41 | 62 | args: ReadonlyArray<string>, |
| 42 | 63 | ) => { stdout?: string; stderr?: string; code?: number }, |
| 43 | 64 | ) { |
| 44 | − return Layer.succeed( | |
| 45 | − ChildProcessSpawner.ChildProcessSpawner, | |
| 46 | − ChildProcessSpawner.make((command) => { | |
| 65 | + return Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { | |
| 66 | + spawn: (command) => { | |
| 47 | 67 | const childProcess = command as unknown as { |
| 48 | 68 | readonly command: string; |
| 49 | 69 | readonly args: ReadonlyArray<string>; |
| 50 | 70 | }; |
| 51 | 71 | return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args))); |
| 52 | − }), | |
| 53 | − ); | |
| 72 | + }, | |
| 73 | + }); | |
| 54 | 74 | } |
| 55 | 75 | |
| 56 | 76 | describe("tailscale", () => { |
| @@ -66,8 +86,8 @@ describe("tailscale", () => { · 9 unmodified lines | ||
| 66 | 86 | it.effect("parses MagicDNS names from tailscale status", () => |
| 67 | 87 | Effect.gen(function* () { |
| 68 | 88 | const dnsName = yield* parseTailscaleMagicDnsName(tailscaleStatusJson); |
| 69 | − assert.equal(dnsName, "desktop.tail.ts.net"); | |
| 70 | − assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); | |
| 89 | + assert.deepEqual(dnsName, Option.some("desktop.tail.ts.net")); | |
| 90 | + assert.deepEqual(yield* parseTailscaleMagicDnsName("{}"), Option.none()); | |
| 71 | 91 | }), |
| 72 | 92 | ); |
| 73 | 93 | |
| @@ -75,7 +95,7 @@ describe("tailscale", () => { · 1 unmodified line | ||
| 75 | 95 | Effect.gen(function* () { |
| 76 | 96 | const status = yield* parseTailscaleStatus(tailscaleStatusJson); |
| 77 | 97 | assert.deepEqual(status, { |
| 78 | − magicDnsName: "desktop.tail.ts.net", | |
| 98 | + magicDnsName: Option.some("desktop.tail.ts.net"), | |
| 79 | 99 | tailnetIpv4Addresses: ["100.100.100.100"], |
| 80 | 100 | }); |
| 81 | 101 | }), |
| @@ -106,12 +126,31 @@ describe("tailscale", () => { · 24 unmodified lines | ||
| 106 | 126 | return Effect.gen(function* () { |
| 107 | 127 | const status = yield* readTailscaleStatus.pipe(Effect.provide(layer)); |
| 108 | 128 | assert.deepEqual(status, { |
| 109 | − magicDnsName: "desktop.tail.ts.net", | |
| 129 | + magicDnsName: Option.some("desktop.tail.ts.net"), | |
| 110 | 130 | tailnetIpv4Addresses: ["100.90.1.2"], |
| 111 | 131 | }); |
| 112 | 132 | }); |
| 113 | 133 | }); |
| 114 | 134 | |
| 135 | + it.effect("times out tailscale status with TestClock", () => { | |
| 136 | + const layer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { | |
| 137 | + spawn: () => Effect.succeed(mockHangingHandle()), | |
| 138 | + }); | |
| 139 | + | |
| 140 | + return Effect.gen(function* () { | |
| 141 | + const fiber = yield* readTailscaleStatus.pipe(Effect.provide(layer), Effect.result, Effect.fork); | |
| 142 | + yield* Effect.yieldNow; | |
| 143 | + yield* TestClock.adjust(Duration.sum(TAILSCALE_STATUS_TIMEOUT, Duration.millis(1))); | |
| 144 | + const result = yield* Fiber.join(fiber); | |
| 145 | + | |
| 146 | + assert.equal(result._tag, "Failure"); | |
| 147 | + if (result._tag === "Failure") { | |
| 148 | + assert.equal(result.failure._tag, "TailscaleCommandError"); | |
| 149 | + assert.equal(result.failure.message, "Tailscale status timed out."); | |
| 150 | + } | |
| 151 | + }).pipe(Effect.provide(TestClock.layer())); | |
| 152 | + }); | |
| 153 | + | |
| 115 | 154 | it.effect("configures tailscale serve through the process spawner service", () => { |
| 116 | 155 | const layer = mockSpawnerLayer((command, args) => { |
| 117 | 156 | assert.equal(command, "tailscale"); |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import * as Data from "effect/Data"; | |
| 1 | +import * as Duration from "effect/Duration"; | |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Option from "effect/Option"; |
| 4 | 4 | import * as Schema from "effect/Schema"; |
| @@ -7,24 +7,33 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; · 2 unmodified lines | ||
| 7 | 7 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 8 | 8 | |
| 9 | 9 | export const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 10 | −export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; | |
| 11 | −export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000; | |
| 12 | −export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500; | |
| 10 | +export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500); | |
| 11 | +export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); | |
| 12 | +export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); | |
| 13 | 13 | |
| 14 | −export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ | |
| 15 | − readonly command: readonly string[]; | |
| 16 | − readonly message: string; | |
| 17 | − readonly exitCode: number | null; | |
| 18 | − readonly stderr: string; | |
| 19 | −}> {} | |
| 14 | +export class TailscaleCommandError extends Schema.TaggedErrorClass<TailscaleCommandError>()( | |
| 15 | + "TailscaleCommandError", | |
| 16 | + { | |
| 17 | + command: Schema.Array(Schema.String), | |
| 18 | + message: Schema.String, | |
| 19 | + exitCode: Schema.Union([Schema.Number, Schema.Null]), | |
| 20 | + stderr: Schema.String, | |
| 21 | + }, | |
| 22 | +) {} | |
| 20 | 23 | |
| 21 | −export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{ | |
| 22 | − readonly cause: unknown; | |
| 23 | −}> {} | |
| 24 | +export class TailscaleStatusParseError extends Schema.TaggedErrorClass<TailscaleStatusParseError>()( | |
| 25 | + "TailscaleStatusParseError", | |
| 26 | + { | |
| 27 | + cause: Schema.Defect, | |
| 28 | + }, | |
| 29 | +) {} | |
| 24 | 30 | |
| 25 | −export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{ | |
| 26 | − readonly reason: string; | |
| 27 | −}> {} | |
| 31 | +export class TailscaleUnavailableError extends Schema.TaggedErrorClass<TailscaleUnavailableError>()( | |
| 32 | + "TailscaleUnavailableError", | |
| 33 | + { | |
| 34 | + reason: Schema.String, | |
| 35 | + }, | |
| 36 | +) {} | |
| 28 | 37 | |
| 29 | 38 | const TailscaleStatusSelf = Schema.Struct({ |
| 30 | 39 | DNSName: Schema.optional(Schema.Unknown), |
| @@ -39,7 +48,7 @@ export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type; · 8 unmodified lines | ||
| 39 | 48 | export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; |
| 40 | 49 | |
| 41 | 50 | export interface TailscaleStatus { |
| 42 | − readonly magicDnsName: string | null; | |
| 51 | + readonly magicDnsName: Option.Option<string>; | |
| 43 | 52 | readonly tailnetIpv4Addresses: readonly string[]; |
| 44 | 53 | } |
| 45 | 54 | |
| @@ -69,19 +78,19 @@ const tailscaleCommandError = ( · 23 unmodified lines | ||
| 69 | 78 | |
| 70 | 79 | const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); |
| 71 | 80 | |
| 72 | −function normalizeMagicDnsName(status: TailscaleStatusJson): string | null { | |
| 81 | +function normalizeMagicDnsName(status: TailscaleStatusJson): Option.Option<string> { | |
| 73 | 82 | const dnsName = status.Self?.DNSName; |
| 74 | 83 | if (typeof dnsName !== "string") { |
| 75 | − return null; | |
| 84 | + return Option.none(); | |
| 76 | 85 | } |
| 77 | 86 | |
| 78 | 87 | const normalized = dnsName.trim().replace(/\.$/u, ""); |
| 79 | − return normalized.length > 0 ? normalized : null; | |
| 88 | + return normalized.length > 0 ? Option.some(normalized) : Option.none(); | |
| 80 | 89 | } |
| 81 | 90 | |
| 82 | 91 | export const parseTailscaleMagicDnsName = ( |
| 83 | 92 | rawStatusJson: string, |
| 84 | −): Effect.Effect<string | null, TailscaleStatusParseError> => | |
| 93 | +): Effect.Effect<Option.Option<string>, TailscaleStatusParseError> => | |
| 85 | 94 | decodeTailscaleStatusJson(rawStatusJson).pipe( |
| 86 | 95 | Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), |
| 87 | 96 | Effect.map(normalizeMagicDnsName), |
| @@ -177,7 +186,7 @@ export const readTailscaleStatus: Effect.Effect< · 89 unmodified lines | ||
| 177 | 186 | return yield* parseTailscaleStatus(stdout); |
| 178 | 187 | }).pipe( |
| 179 | 188 | Effect.scoped, |
| 180 | − Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS), | |
| 189 | + Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT), | |
| 181 | 190 | Effect.flatMap((result) => |
| 182 | 191 | Option.match(result, { |
| 183 | 192 | onNone: () => |
| @@ -209,7 +218,7 @@ const runTailscaleCommand = ( · 25 unmodified lines | ||
| 209 | 218 | readonly runMessage: string; |
| 210 | 219 | readonly exitMessage: (exitCode: number) => string; |
| 211 | 220 | readonly timeoutMessage: string; |
| 212 | − readonly timeoutMs: number; | |
| 221 | + readonly timeout: Duration.Input; | |
| 213 | 222 | }, |
| 214 | 223 | ): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => |
| 215 | 224 | Effect.gen(function* () { |
| @@ -246,7 +255,7 @@ const runTailscaleCommand = ( · 30 unmodified lines | ||
| 246 | 255 | } |
| 247 | 256 | }).pipe( |
| 248 | 257 | Effect.scoped, |
| 249 | − Effect.timeoutOption(input.timeoutMs), | |
| 258 | + Effect.timeoutOption(input.timeout), | |
| 250 | 259 | Effect.flatMap((result) => |
| 251 | 260 | Option.match(result, { |
| 252 | 261 | onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)), |
| @@ -268,7 +277,7 @@ export const ensureTailscaleServe = (input: { · 15 unmodified lines | ||
| 268 | 277 | runMessage: "Failed to run tailscale serve.", |
| 269 | 278 | exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`, |
| 270 | 279 | timeoutMessage: "Tailscale serve timed out.", |
| 271 | − timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS, | |
| 280 | + timeout: TAILSCALE_SERVE_TIMEOUT, | |
| 272 | 281 | }); |
| 273 | 282 | }; |
| 274 | 283 | |
| @@ -284,13 +293,13 @@ export const disableTailscaleServe = ( · 9 unmodified lines | ||
| 284 | 293 | runMessage: "Failed to run tailscale serve off.", |
| 285 | 294 | exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`, |
| 286 | 295 | timeoutMessage: "Tailscale serve off timed out.", |
| 287 | − timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS, | |
| 296 | + timeout: TAILSCALE_SERVE_TIMEOUT, | |
| 288 | 297 | }); |
| 289 | 298 | }); |
| 290 | 299 | |
| 291 | 300 | export const probeTailscaleHttpsEndpoint = (input: { |
| 292 | 301 | readonly baseUrl: string; |
| 293 | − readonly timeoutMs?: number; | |
| 302 | + readonly timeout?: Duration.Input; | |
| 294 | 303 | }): Effect.Effect<boolean, never, HttpClient.HttpClient> => |
| 295 | 304 | Effect.gen(function* () { |
| 296 | 305 | const client = yield* HttpClient.HttpClient; |
| @@ -298,7 +307,7 @@ export const probeTailscaleHttpsEndpoint = (input: { · 1 unmodified line | ||
| 298 | 307 | const url = new URL("/.well-known/t3/environment", input.baseUrl); |
| 299 | 308 | const request = HttpClientRequest.get(url.toString()); |
| 300 | 309 | return yield* client.execute(request); |
| 301 | − }).pipe(Effect.timeoutOption(input.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS)); | |
| 310 | + }).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT)); | |
| 302 | 311 | |
| 303 | 312 | return Option.match(response, { |
| 304 | 313 | onNone: () => false, |
| @@ -311,17 +320,17 @@ export const resolveTailscaleHttpsBaseUrl = ( · 6 unmodified lines | ||
| 311 | 320 | readonly servePort?: number; |
| 312 | 321 | } = {}, |
| 313 | 322 | ): Effect.Effect< |
| 314 | − string | null, | |
| 323 | + Option.Option<string>, | |
| 315 | 324 | TailscaleCommandError | TailscaleStatusParseError, |
| 316 | 325 | ChildProcessSpawner.ChildProcessSpawner |
| 317 | 326 | > => |
| 318 | 327 | readTailscaleStatus.pipe( |
| 319 | 328 | Effect.map((status) => |
| 320 | − status.magicDnsName | |
| 321 | − ? buildTailscaleHttpsBaseUrl({ | |
| 322 | − magicDnsName: status.magicDnsName, | |
| 329 | + Option.map(status.magicDnsName, (magicDnsName) => | |
| 330 | + buildTailscaleHttpsBaseUrl({ | |
| 331 | + magicDnsName, | |
| 323 | 332 | ...(input.servePort === undefined ? {} : { servePort: input.servePort }), |
| 324 | − }) | |
| 325 | − : null, | |
| 333 | + }), | |
| 334 | + ), | |
| 326 | 335 | ), |
| 327 | 336 | ); |