| 1 | // @effect-diagnostics nodeBuiltinImport:off - builds real worktree layouts on disk. |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodeOS from "node:os"; |
| 5 | import * as NodePath from "node:path"; |
| 6 | import * as NetService from "@t3tools/shared/Net"; |
| 7 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 8 | import { assert, describe, it } from "@effect/vitest"; |
| 9 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Layer from "effect/Layer"; |
| 12 | import * as Path from "effect/Path"; |
| 13 | import * as PlatformError from "effect/PlatformError"; |
| 14 | import * as Sink from "effect/Sink"; |
| 15 | import * as Stream from "effect/Stream"; |
| 16 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 17 | |
| 18 | import { |
| 19 | checkPortAvailabilityOnHosts, |
| 20 | createDevRunnerEnv, |
| 21 | devPortProbeHosts, |
| 22 | findFirstAvailableOffset, |
| 23 | getDevRunnerModeArgs, |
| 24 | isBrowserAllowedPort, |
| 25 | resolveModePortOffsets, |
| 26 | resolveOffset, |
| 27 | runDevRunnerWithInput, |
| 28 | } from "./dev-runner.ts"; |
| 29 | |
| 30 | const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); |
| 31 | const netServiceLayer = Layer.succeed(NetService.NetService, { |
| 32 | canListenOnHost: () => Effect.succeed(true), |
| 33 | isPortAvailableOnLoopback: () => Effect.succeed(true), |
| 34 | hasListenerOnHost: () => Effect.succeed(false), |
| 35 | reserveLoopbackPort: () => Effect.succeed(49_152), |
| 36 | findAvailablePort: (port) => Effect.succeed(port), |
| 37 | }); |
| 38 | |
| 39 | function mockProcess(exit: number | PlatformError.PlatformError) { |
| 40 | return ChildProcessSpawner.makeHandle({ |
| 41 | pid: ChildProcessSpawner.ProcessId(1), |
| 42 | exitCode: |
| 43 | typeof exit === "number" |
| 44 | ? Effect.succeed(ChildProcessSpawner.ExitCode(exit)) |
| 45 | : Effect.fail(exit), |
| 46 | isRunning: Effect.succeed(false), |
| 47 | kill: () => Effect.void, |
| 48 | unref: Effect.succeed(Effect.void), |
| 49 | stdin: Sink.drain, |
| 50 | stdout: Stream.empty, |
| 51 | stderr: Stream.empty, |
| 52 | all: Stream.empty, |
| 53 | getInputFd: () => Sink.drain, |
| 54 | getOutputFd: () => Stream.empty, |
| 55 | }); |
| 56 | } |
| 57 | |
| 58 | const devServerInput = { |
| 59 | mode: "dev:server", |
| 60 | t3Home: "/tmp/t3code-dev-runner", |
| 61 | browser: undefined, |
| 62 | autoBootstrapProjectFromCwd: undefined, |
| 63 | logWebSocketEvents: undefined, |
| 64 | host: undefined, |
| 65 | port: 13_773, |
| 66 | devUrl: undefined, |
| 67 | dryRun: false, |
| 68 | share: false, |
| 69 | runArgs: ["--inspect", "secret-token-value"], |
| 70 | } as const; |
| 71 | |
| 72 | it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 73 | it.effect("accepts a dry run without the optional browser flag", () => |
| 74 | Effect.gen(function* () { |
| 75 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 76 | const path = yield* Path.Path; |
| 77 | const output = yield* spawner.string( |
| 78 | ChildProcess.make(process.execPath, ["scripts/dev-runner.ts", "dev", "--dry-run"], { |
| 79 | cwd: path.resolve(import.meta.dirname, ".."), |
| 80 | }), |
| 81 | ); |
| 82 | |
| 83 | assert.include(output, "[dev-runner] mode=dev"); |
| 84 | }), |
| 85 | ); |
| 86 | |
| 87 | describe("getDevRunnerModeArgs", () => { |
| 88 | it.effect("lets Vite+ honor the desktop dev task graph", () => |
| 89 | Effect.sync(() => { |
| 90 | assert.deepStrictEqual(getDevRunnerModeArgs("dev:desktop"), [ |
| 91 | "run", |
| 92 | "--filter=@t3tools/desktop", |
| 93 | "--filter=@t3tools/web", |
| 94 | "dev", |
| 95 | ]); |
| 96 | }), |
| 97 | ); |
| 98 | |
| 99 | it.effect("places Vite+ run flags before the task name", () => |
| 100 | Effect.sync(() => { |
| 101 | assert.deepStrictEqual(getDevRunnerModeArgs("dev"), [ |
| 102 | "run", |
| 103 | "--filter=@t3tools/contracts", |
| 104 | "--filter=@t3tools/web", |
| 105 | "--filter=t3", |
| 106 | "--parallel", |
| 107 | "dev", |
| 108 | ]); |
| 109 | }), |
| 110 | ); |
| 111 | }); |
| 112 | |
| 113 | describe("resolveOffset", () => { |
| 114 | it.effect("uses explicit T3CODE_PORT_OFFSET when provided", () => |
| 115 | Effect.gen(function* () { |
| 116 | const result = yield* resolveOffset({ portOffset: 12, devInstance: undefined }); |
| 117 | assert.deepStrictEqual(result, { |
| 118 | offset: 12, |
| 119 | source: "T3CODE_PORT_OFFSET=12", |
| 120 | }); |
| 121 | }), |
| 122 | ); |
| 123 | |
| 124 | it.effect("hashes non-numeric instance values", () => |
| 125 | Effect.gen(function* () { |
| 126 | const result = yield* resolveOffset({ |
| 127 | portOffset: undefined, |
| 128 | devInstance: "feature-branch", |
| 129 | }); |
| 130 | assert.ok(result.offset >= 1); |
| 131 | assert.ok(result.offset <= 3000); |
| 132 | }), |
| 133 | ); |
| 134 | |
| 135 | it.effect("returns structured context for a negative port offset", () => |
| 136 | Effect.gen(function* () { |
| 137 | const error = yield* resolveOffset({ portOffset: -1, devInstance: undefined }).pipe( |
| 138 | Effect.flip, |
| 139 | ); |
| 140 | |
| 141 | assert.equal(error._tag, "DevRunnerInvalidPortOffsetError"); |
| 142 | assert.equal(error.configKey, "T3CODE_PORT_OFFSET"); |
| 143 | assert.equal(error.portOffset, -1); |
| 144 | assert.equal(error.minimum, 0); |
| 145 | assert.ok(!("cause" in error)); |
| 146 | }), |
| 147 | ); |
| 148 | }); |
| 149 | |
| 150 | describe("createDevRunnerEnv", () => { |
| 151 | it.effect("forwards the reusable auth token to web dev and removes it for desktop", () => |
| 152 | Effect.gen(function* () { |
| 153 | const input = { |
| 154 | baseEnv: { T3CODE_DEV_AUTH_TOKEN: "reusable-dev-auth-token-that-is-long-enough" }, |
| 155 | serverOffset: 0, |
| 156 | webOffset: 0, |
| 157 | t3Home: undefined, |
| 158 | browser: undefined, |
| 159 | autoBootstrapProjectFromCwd: undefined, |
| 160 | logWebSocketEvents: undefined, |
| 161 | host: undefined, |
| 162 | port: undefined, |
| 163 | devUrl: undefined, |
| 164 | } as const; |
| 165 | const web = yield* createDevRunnerEnv({ ...input, mode: "dev" }); |
| 166 | const desktop = yield* createDevRunnerEnv({ ...input, mode: "dev:desktop" }); |
| 167 | |
| 168 | assert.equal(web.T3CODE_DEV_AUTH_TOKEN, input.baseEnv.T3CODE_DEV_AUTH_TOKEN); |
| 169 | assert.equal(desktop.T3CODE_DEV_AUTH_TOKEN, undefined); |
| 170 | }), |
| 171 | ); |
| 172 | it.effect("leaves the shared home implicit and disables browser auto-open", () => |
| 173 | Effect.gen(function* () { |
| 174 | const env = yield* createDevRunnerEnv({ |
| 175 | mode: "dev", |
| 176 | baseEnv: {}, |
| 177 | serverOffset: 0, |
| 178 | webOffset: 0, |
| 179 | t3Home: undefined, |
| 180 | browser: undefined, |
| 181 | autoBootstrapProjectFromCwd: undefined, |
| 182 | logWebSocketEvents: undefined, |
| 183 | host: undefined, |
| 184 | port: undefined, |
| 185 | devUrl: undefined, |
| 186 | }); |
| 187 | |
| 188 | assert.equal(env.T3CODE_HOME, undefined); |
| 189 | assert.equal(env.T3CODE_NO_BROWSER, "1"); |
| 190 | }), |
| 191 | ); |
| 192 | |
| 193 | it.effect("allows browser auto-open to be explicitly enabled", () => |
| 194 | Effect.gen(function* () { |
| 195 | const env = yield* createDevRunnerEnv({ |
| 196 | mode: "dev", |
| 197 | baseEnv: {}, |
| 198 | serverOffset: 0, |
| 199 | webOffset: 0, |
| 200 | t3Home: undefined, |
| 201 | browser: true, |
| 202 | autoBootstrapProjectFromCwd: undefined, |
| 203 | logWebSocketEvents: undefined, |
| 204 | host: undefined, |
| 205 | port: undefined, |
| 206 | devUrl: undefined, |
| 207 | }); |
| 208 | |
| 209 | assert.equal(env.T3CODE_NO_BROWSER, "0"); |
| 210 | }), |
| 211 | ); |
| 212 | |
| 213 | it.effect("requires the browser flag even when the environment enables auto-open", () => |
| 214 | Effect.gen(function* () { |
| 215 | const env = yield* createDevRunnerEnv({ |
| 216 | mode: "dev", |
| 217 | baseEnv: { T3CODE_NO_BROWSER: "0" }, |
| 218 | serverOffset: 0, |
| 219 | webOffset: 0, |
| 220 | t3Home: undefined, |
| 221 | browser: false, |
| 222 | autoBootstrapProjectFromCwd: undefined, |
| 223 | logWebSocketEvents: undefined, |
| 224 | host: undefined, |
| 225 | port: undefined, |
| 226 | devUrl: undefined, |
| 227 | }); |
| 228 | |
| 229 | assert.equal(env.T3CODE_NO_BROWSER, "1"); |
| 230 | }), |
| 231 | ); |
| 232 | |
| 233 | it.effect("supports explicit typed overrides", () => |
| 234 | Effect.gen(function* () { |
| 235 | const path = yield* Path.Path; |
| 236 | const env = yield* createDevRunnerEnv({ |
| 237 | mode: "dev:server", |
| 238 | baseEnv: {}, |
| 239 | serverOffset: 0, |
| 240 | webOffset: 0, |
| 241 | t3Home: "/tmp/custom-t3", |
| 242 | browser: false, |
| 243 | autoBootstrapProjectFromCwd: false, |
| 244 | logWebSocketEvents: true, |
| 245 | host: "0.0.0.0", |
| 246 | port: 4222, |
| 247 | devUrl: new URL("http://localhost:7331"), |
| 248 | }); |
| 249 | |
| 250 | assert.equal(env.T3CODE_HOME, path.resolve("/tmp/custom-t3")); |
| 251 | assert.equal(env.T3CODE_PORT, "4222"); |
| 252 | assert.equal(env.VITE_HTTP_URL, "http://localhost:4222"); |
| 253 | assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); |
| 254 | assert.equal(env.T3CODE_NO_BROWSER, "1"); |
| 255 | assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0"); |
| 256 | assert.equal(env.T3CODE_LOG_WS_EVENTS, "1"); |
| 257 | assert.equal(env.T3CODE_HOST, "0.0.0.0"); |
| 258 | assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/"); |
| 259 | }), |
| 260 | ); |
| 261 | |
| 262 | it.effect("strips inherited service-launcher context", () => |
| 263 | Effect.gen(function* () { |
| 264 | const env = yield* createDevRunnerEnv({ |
| 265 | mode: "dev", |
| 266 | baseEnv: { |
| 267 | T3_SERVICE_LAUNCHER_CONTEXT: '{"childVersion":"9.9.9"}', |
| 268 | T3_BOOT_SERVICE_UNIT: "t3code.service", |
| 269 | }, |
| 270 | serverOffset: 0, |
| 271 | webOffset: 0, |
| 272 | t3Home: undefined, |
| 273 | browser: undefined, |
| 274 | autoBootstrapProjectFromCwd: undefined, |
| 275 | logWebSocketEvents: undefined, |
| 276 | host: undefined, |
| 277 | port: undefined, |
| 278 | devUrl: undefined, |
| 279 | }); |
| 280 | |
| 281 | assert.equal(env.T3_SERVICE_LAUNCHER_CONTEXT, undefined); |
| 282 | assert.equal(env.T3_BOOT_SERVICE_UNIT, undefined); |
| 283 | }), |
| 284 | ); |
| 285 | |
| 286 | it.effect("does not force websocket logging on in dev mode when unset", () => |
| 287 | Effect.gen(function* () { |
| 288 | const env = yield* createDevRunnerEnv({ |
| 289 | mode: "dev", |
| 290 | baseEnv: { |
| 291 | T3CODE_LOG_WS_EVENTS: "keep-me-out", |
| 292 | }, |
| 293 | serverOffset: 0, |
| 294 | webOffset: 0, |
| 295 | t3Home: undefined, |
| 296 | browser: undefined, |
| 297 | autoBootstrapProjectFromCwd: undefined, |
| 298 | logWebSocketEvents: undefined, |
| 299 | host: undefined, |
| 300 | port: undefined, |
| 301 | devUrl: undefined, |
| 302 | }); |
| 303 | |
| 304 | assert.equal(env.T3CODE_MODE, "web"); |
| 305 | assert.equal(env.T3CODE_LOG_WS_EVENTS, undefined); |
| 306 | }), |
| 307 | ); |
| 308 | |
| 309 | it.effect("forwards explicit websocket logging false without coercing it away", () => |
| 310 | Effect.gen(function* () { |
| 311 | const env = yield* createDevRunnerEnv({ |
| 312 | mode: "dev", |
| 313 | baseEnv: { |
| 314 | T3CODE_LOG_WS_EVENTS: "1", |
| 315 | }, |
| 316 | serverOffset: 0, |
| 317 | webOffset: 0, |
| 318 | t3Home: undefined, |
| 319 | browser: undefined, |
| 320 | autoBootstrapProjectFromCwd: undefined, |
| 321 | logWebSocketEvents: false, |
| 322 | host: undefined, |
| 323 | port: undefined, |
| 324 | devUrl: undefined, |
| 325 | }); |
| 326 | |
| 327 | assert.equal(env.T3CODE_LOG_WS_EVENTS, "0"); |
| 328 | }), |
| 329 | ); |
| 330 | |
| 331 | it.effect("uses custom t3Home when provided", () => |
| 332 | Effect.gen(function* () { |
| 333 | const path = yield* Path.Path; |
| 334 | const env = yield* createDevRunnerEnv({ |
| 335 | mode: "dev", |
| 336 | baseEnv: {}, |
| 337 | serverOffset: 0, |
| 338 | webOffset: 0, |
| 339 | t3Home: "/tmp/my-t3", |
| 340 | browser: undefined, |
| 341 | autoBootstrapProjectFromCwd: undefined, |
| 342 | logWebSocketEvents: undefined, |
| 343 | host: undefined, |
| 344 | port: undefined, |
| 345 | devUrl: undefined, |
| 346 | }); |
| 347 | |
| 348 | assert.equal(env.T3CODE_HOME, path.resolve("/tmp/my-t3")); |
| 349 | }), |
| 350 | ); |
| 351 | |
| 352 | it.effect("pins desktop dev to a stable backend port and websocket url", () => |
| 353 | Effect.gen(function* () { |
| 354 | const path = yield* Path.Path; |
| 355 | const env = yield* createDevRunnerEnv({ |
| 356 | mode: "dev:desktop", |
| 357 | baseEnv: { |
| 358 | T3CODE_PORT: "13773", |
| 359 | T3CODE_MODE: "web", |
| 360 | T3CODE_NO_BROWSER: "0", |
| 361 | T3CODE_HOST: "0.0.0.0", |
| 362 | VITE_DEV_SERVER_URL: "http://127.0.0.1:8526", |
| 363 | VITE_WS_URL: "ws://localhost:13773", |
| 364 | }, |
| 365 | serverOffset: 0, |
| 366 | webOffset: 0, |
| 367 | t3Home: "/tmp/my-t3", |
| 368 | browser: true, |
| 369 | autoBootstrapProjectFromCwd: undefined, |
| 370 | logWebSocketEvents: undefined, |
| 371 | host: "127.0.0.1", |
| 372 | port: 4222, |
| 373 | devUrl: undefined, |
| 374 | }); |
| 375 | |
| 376 | assert.equal(env.T3CODE_HOME, path.resolve("/tmp/my-t3")); |
| 377 | assert.equal(env.PORT, "5733"); |
| 378 | assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); |
| 379 | assert.equal(env.HOST, "127.0.0.1"); |
| 380 | assert.equal(env.T3CODE_PORT, "4222"); |
| 381 | assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:4222"); |
| 382 | assert.equal(env.T3CODE_MODE, undefined); |
| 383 | assert.equal(env.T3CODE_NO_BROWSER, undefined); |
| 384 | assert.equal(env.T3CODE_HOST, undefined); |
| 385 | assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); |
| 386 | }), |
| 387 | ); |
| 388 | |
| 389 | it.effect("defaults dev server mode to the higher backend port range", () => |
| 390 | Effect.gen(function* () { |
| 391 | const env = yield* createDevRunnerEnv({ |
| 392 | mode: "dev", |
| 393 | baseEnv: {}, |
| 394 | serverOffset: 0, |
| 395 | webOffset: 0, |
| 396 | t3Home: undefined, |
| 397 | browser: undefined, |
| 398 | autoBootstrapProjectFromCwd: undefined, |
| 399 | logWebSocketEvents: undefined, |
| 400 | host: undefined, |
| 401 | port: undefined, |
| 402 | devUrl: undefined, |
| 403 | }); |
| 404 | |
| 405 | assert.equal(env.T3CODE_PORT, "13773"); |
| 406 | assert.equal(env.PORT, "5733"); |
| 407 | }), |
| 408 | ); |
| 409 | |
| 410 | // Browser dev is single-origin: Vite proxies the backend, and the client |
| 411 | // resolves it from window.location.origin. Baking a localhost URL here is |
| 412 | // what breaks sharing a dev server to another device. |
| 413 | it.effect.each(["dev", "dev:web"] as const)( |
| 414 | "leaves the client backend URLs unset in %s mode", |
| 415 | (mode) => |
| 416 | Effect.gen(function* () { |
| 417 | const env = yield* createDevRunnerEnv({ |
| 418 | mode, |
| 419 | baseEnv: { |
| 420 | VITE_HTTP_URL: "http://localhost:1234", |
| 421 | VITE_WS_URL: "ws://localhost:1234", |
| 422 | }, |
| 423 | serverOffset: 0, |
| 424 | webOffset: 0, |
| 425 | t3Home: undefined, |
| 426 | browser: undefined, |
| 427 | autoBootstrapProjectFromCwd: undefined, |
| 428 | logWebSocketEvents: undefined, |
| 429 | host: undefined, |
| 430 | port: undefined, |
| 431 | devUrl: undefined, |
| 432 | }); |
| 433 | |
| 434 | assert.equal(env.VITE_HTTP_URL, undefined); |
| 435 | assert.equal(env.VITE_WS_URL, undefined); |
| 436 | assert.equal(env.T3CODE_PORT, "13773"); |
| 437 | // Deleting the keys is not sufficient — vite.config.ts merges |
| 438 | // `.env`/`.env.local` underneath this env and would revive them, so |
| 439 | // the intent has to be stated positively. |
| 440 | assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, "1"); |
| 441 | }), |
| 442 | ); |
| 443 | |
| 444 | // Desktop pins the renderer at loopback deliberately; an ambient marker |
| 445 | // must not make Vite discard those URLs. |
| 446 | it.effect("clears the single-origin marker in dev:desktop mode", () => |
| 447 | Effect.gen(function* () { |
| 448 | const env = yield* createDevRunnerEnv({ |
| 449 | mode: "dev:desktop", |
| 450 | baseEnv: { T3CODE_SINGLE_ORIGIN_DEV: "1" }, |
| 451 | serverOffset: 0, |
| 452 | webOffset: 0, |
| 453 | t3Home: undefined, |
| 454 | browser: undefined, |
| 455 | autoBootstrapProjectFromCwd: undefined, |
| 456 | logWebSocketEvents: undefined, |
| 457 | host: undefined, |
| 458 | port: undefined, |
| 459 | devUrl: undefined, |
| 460 | }); |
| 461 | |
| 462 | assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, undefined); |
| 463 | assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:13773"); |
| 464 | }), |
| 465 | ); |
| 466 | |
| 467 | it.effect("clears the single-origin marker in dev:server mode", () => |
| 468 | Effect.gen(function* () { |
| 469 | const env = yield* createDevRunnerEnv({ |
| 470 | mode: "dev:server", |
| 471 | baseEnv: { T3CODE_SINGLE_ORIGIN_DEV: "1" }, |
| 472 | serverOffset: 0, |
| 473 | webOffset: 0, |
| 474 | t3Home: undefined, |
| 475 | browser: undefined, |
| 476 | autoBootstrapProjectFromCwd: undefined, |
| 477 | logWebSocketEvents: undefined, |
| 478 | host: undefined, |
| 479 | port: undefined, |
| 480 | devUrl: undefined, |
| 481 | }); |
| 482 | |
| 483 | assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, undefined); |
| 484 | assert.equal(env.VITE_HTTP_URL, "http://localhost:13773"); |
| 485 | }), |
| 486 | ); |
| 487 | |
| 488 | // HOST is Vite's bind address and gates the HMR pin in vite.config.ts. An |
| 489 | // inherited one would survive into browser dev and point HMR at the wrong |
| 490 | // interface — invisible over a shared origin, since the page still loads. |
| 491 | it.effect.each(["dev", "dev:web"] as const)("drops an inherited HOST in %s mode", (mode) => |
| 492 | Effect.gen(function* () { |
| 493 | const env = yield* createDevRunnerEnv({ |
| 494 | mode, |
| 495 | baseEnv: { HOST: "0.0.0.0" }, |
| 496 | serverOffset: 0, |
| 497 | webOffset: 0, |
| 498 | t3Home: undefined, |
| 499 | browser: undefined, |
| 500 | autoBootstrapProjectFromCwd: undefined, |
| 501 | logWebSocketEvents: undefined, |
| 502 | host: undefined, |
| 503 | port: undefined, |
| 504 | devUrl: undefined, |
| 505 | }); |
| 506 | |
| 507 | assert.equal(env.HOST, undefined); |
| 508 | }), |
| 509 | ); |
| 510 | |
| 511 | // --host configures the *backend* (T3CODE_HOST). It must not become Vite's |
| 512 | // bind address by way of an inherited HOST that happens to agree with it. |
| 513 | it.effect("drops an inherited HOST even when --host is given", () => |
| 514 | Effect.gen(function* () { |
| 515 | const env = yield* createDevRunnerEnv({ |
| 516 | mode: "dev", |
| 517 | baseEnv: { HOST: "0.0.0.0" }, |
| 518 | serverOffset: 0, |
| 519 | webOffset: 0, |
| 520 | t3Home: undefined, |
| 521 | browser: undefined, |
| 522 | autoBootstrapProjectFromCwd: undefined, |
| 523 | logWebSocketEvents: undefined, |
| 524 | host: "0.0.0.0", |
| 525 | port: undefined, |
| 526 | devUrl: undefined, |
| 527 | }); |
| 528 | |
| 529 | assert.equal(env.HOST, undefined); |
| 530 | assert.equal(env.T3CODE_HOST, "0.0.0.0"); |
| 531 | }), |
| 532 | ); |
| 533 | |
| 534 | // Desktop sets HOST itself, so the clearing must not reach it. |
| 535 | it.effect("still pins HOST for dev:desktop", () => |
| 536 | Effect.gen(function* () { |
| 537 | const env = yield* createDevRunnerEnv({ |
| 538 | mode: "dev:desktop", |
| 539 | baseEnv: { HOST: "0.0.0.0" }, |
| 540 | serverOffset: 0, |
| 541 | webOffset: 0, |
| 542 | t3Home: undefined, |
| 543 | browser: undefined, |
| 544 | autoBootstrapProjectFromCwd: undefined, |
| 545 | logWebSocketEvents: undefined, |
| 546 | host: undefined, |
| 547 | port: undefined, |
| 548 | devUrl: undefined, |
| 549 | }); |
| 550 | |
| 551 | assert.equal(env.HOST, "127.0.0.1"); |
| 552 | }), |
| 553 | ); |
| 554 | |
| 555 | it.effect("keeps explicit backend URLs for the desktop renderer", () => |
| 556 | Effect.gen(function* () { |
| 557 | const env = yield* createDevRunnerEnv({ |
| 558 | mode: "dev:desktop", |
| 559 | baseEnv: {}, |
| 560 | serverOffset: 0, |
| 561 | webOffset: 0, |
| 562 | t3Home: undefined, |
| 563 | browser: undefined, |
| 564 | autoBootstrapProjectFromCwd: undefined, |
| 565 | logWebSocketEvents: undefined, |
| 566 | host: undefined, |
| 567 | port: undefined, |
| 568 | devUrl: undefined, |
| 569 | }); |
| 570 | |
| 571 | assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:13773"); |
| 572 | assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:13773"); |
| 573 | }), |
| 574 | ); |
| 575 | }); |
| 576 | |
| 577 | describe("findFirstAvailableOffset", () => { |
| 578 | it.effect("returns the starting offset when required ports are available", () => |
| 579 | Effect.gen(function* () { |
| 580 | const offset = yield* findFirstAvailableOffset({ |
| 581 | startOffset: 0, |
| 582 | requireServerPort: true, |
| 583 | requireWebPort: true, |
| 584 | checkPortAvailability: () => Effect.succeed(true), |
| 585 | }); |
| 586 | |
| 587 | assert.equal(offset, 0); |
| 588 | }), |
| 589 | ); |
| 590 | |
| 591 | it.effect("advances until all required ports are available", () => |
| 592 | Effect.gen(function* () { |
| 593 | const taken = new Set([13773, 5733, 13774, 5734]); |
| 594 | const offset = yield* findFirstAvailableOffset({ |
| 595 | startOffset: 0, |
| 596 | requireServerPort: true, |
| 597 | requireWebPort: true, |
| 598 | checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), |
| 599 | }); |
| 600 | |
| 601 | assert.equal(offset, 2); |
| 602 | }), |
| 603 | ); |
| 604 | |
| 605 | it.effect("skips browser-blocked web ports before probing availability", () => |
| 606 | Effect.gen(function* () { |
| 607 | const probed: Array<{ port: number; role: string | undefined }> = []; |
| 608 | const offset = yield* findFirstAvailableOffset({ |
| 609 | // 5733 + 833 = 6566, which browsers block as sane-port. |
| 610 | startOffset: 833, |
| 611 | requireServerPort: true, |
| 612 | requireWebPort: true, |
| 613 | checkPortAvailability: (port, role) => { |
| 614 | probed.push({ port, role }); |
| 615 | return Effect.succeed(true); |
| 616 | }, |
| 617 | }); |
| 618 | |
| 619 | assert.equal(offset, 834); |
| 620 | assert.deepStrictEqual(probed, [ |
| 621 | { port: 14_607, role: "server" }, |
| 622 | { port: 6567, role: "web" }, |
| 623 | ]); |
| 624 | }), |
| 625 | ); |
| 626 | |
| 627 | it.effect("does not reject a server-only offset because its unused web port is blocked", () => |
| 628 | Effect.gen(function* () { |
| 629 | const offset = yield* findFirstAvailableOffset({ |
| 630 | startOffset: 833, |
| 631 | requireServerPort: true, |
| 632 | requireWebPort: false, |
| 633 | checkPortAvailability: () => Effect.succeed(true), |
| 634 | }); |
| 635 | |
| 636 | assert.equal(offset, 833); |
| 637 | }), |
| 638 | ); |
| 639 | |
| 640 | it.effect("allows offsets where the non-required server port exceeds max", () => |
| 641 | Effect.gen(function* () { |
| 642 | const offset = yield* findFirstAvailableOffset({ |
| 643 | startOffset: 59_802, |
| 644 | requireServerPort: false, |
| 645 | requireWebPort: true, |
| 646 | checkPortAvailability: () => Effect.succeed(true), |
| 647 | }); |
| 648 | |
| 649 | assert.equal(offset, 59_802); |
| 650 | }), |
| 651 | ); |
| 652 | |
| 653 | it.effect("reports the exhausted range and required port set", () => |
| 654 | Effect.gen(function* () { |
| 655 | const error = yield* findFirstAvailableOffset({ |
| 656 | startOffset: 51_763, |
| 657 | requireServerPort: true, |
| 658 | requireWebPort: false, |
| 659 | checkPortAvailability: () => Effect.succeed(true), |
| 660 | }).pipe(Effect.flip); |
| 661 | |
| 662 | if (error._tag !== "DevRunnerPortExhaustedError") { |
| 663 | assert.fail(`Unexpected error: ${error._tag}`); |
| 664 | } |
| 665 | assert.equal(error.startOffset, 51_763); |
| 666 | assert.equal(error.requireServerPort, true); |
| 667 | assert.equal(error.requireWebPort, false); |
| 668 | assert.equal(error.baseServerPort, 13_773); |
| 669 | assert.equal(error.baseWebPort, 5_733); |
| 670 | assert.equal(error.maximumPort, 65_535); |
| 671 | assert.ok(!("cause" in error)); |
| 672 | }), |
| 673 | ); |
| 674 | }); |
| 675 | |
| 676 | describe("isBrowserAllowedPort", () => { |
| 677 | it.each([6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697])( |
| 678 | "rejects Fetch-blocked web port %s from the worktree offset range", |
| 679 | (port) => { |
| 680 | assert.equal(isBrowserAllowedPort(port), false); |
| 681 | }, |
| 682 | ); |
| 683 | |
| 684 | it.each([5733, 5900, 6567, 6670, 8733])("allows browser-safe web port %s", (port) => { |
| 685 | assert.equal(isBrowserAllowedPort(port), true); |
| 686 | }); |
| 687 | }); |
| 688 | |
| 689 | describe("checkPortAvailabilityOnHosts", () => { |
| 690 | it.effect("checks overlapping hosts sequentially to avoid self-interference", () => |
| 691 | Effect.gen(function* () { |
| 692 | let inFlightCount = 0; |
| 693 | const calls: Array<[number, string]> = []; |
| 694 | |
| 695 | const available = yield* checkPortAvailabilityOnHosts( |
| 696 | 13_773, |
| 697 | ["127.0.0.1", "0.0.0.0", "::"], |
| 698 | (port, host) => |
| 699 | Effect.promise(async () => { |
| 700 | calls.push([port, host]); |
| 701 | inFlightCount += 1; |
| 702 | const overlapped = inFlightCount > 1; |
| 703 | await Promise.resolve(); |
| 704 | inFlightCount -= 1; |
| 705 | return !overlapped; |
| 706 | }), |
| 707 | ); |
| 708 | |
| 709 | assert.equal(available, true); |
| 710 | assert.deepStrictEqual(calls, [ |
| 711 | [13_773, "127.0.0.1"], |
| 712 | [13_773, "0.0.0.0"], |
| 713 | [13_773, "::"], |
| 714 | ]); |
| 715 | }), |
| 716 | ); |
| 717 | }); |
| 718 | |
| 719 | describe("devPortProbeHosts", () => { |
| 720 | it.effect("probes loopback only when no bind host is configured", () => |
| 721 | Effect.sync(() => { |
| 722 | assert.deepStrictEqual(devPortProbeHosts(undefined), ["127.0.0.1", "::1"]); |
| 723 | assert.deepStrictEqual(devPortProbeHosts(" "), ["127.0.0.1", "::1"]); |
| 724 | }), |
| 725 | ); |
| 726 | |
| 727 | // A port free on loopback can be taken on the interface the server will |
| 728 | // actually bind, so --host/T3CODE_HOST has to be probed as well. |
| 729 | it.effect("adds a non-loopback bind host to the probe list", () => |
| 730 | Effect.sync(() => { |
| 731 | assert.deepStrictEqual(devPortProbeHosts("0.0.0.0"), ["127.0.0.1", "::1", "0.0.0.0"]); |
| 732 | assert.deepStrictEqual(devPortProbeHosts("192.168.1.10"), [ |
| 733 | "127.0.0.1", |
| 734 | "::1", |
| 735 | "192.168.1.10", |
| 736 | ]); |
| 737 | }), |
| 738 | ); |
| 739 | |
| 740 | it.effect("does not probe loopback twice when it is the configured host", () => |
| 741 | Effect.sync(() => { |
| 742 | assert.deepStrictEqual(devPortProbeHosts("127.0.0.1"), ["127.0.0.1", "::1"]); |
| 743 | }), |
| 744 | ); |
| 745 | |
| 746 | // Only the backend honours --host/T3CODE_HOST. Vite reads HOST (set for |
| 747 | // desktop only), so judging the web port against the backend's interface |
| 748 | // would reject ports for a server that never binds there. |
| 749 | it.effect("passes the port role so only the server port sees the bind host", () => |
| 750 | Effect.gen(function* () { |
| 751 | const probed: Array<{ port: number; role: string | undefined }> = []; |
| 752 | |
| 753 | yield* resolveModePortOffsets({ |
| 754 | mode: "dev", |
| 755 | startOffset: 0, |
| 756 | hasExplicitServerPort: false, |
| 757 | hasExplicitDevUrl: false, |
| 758 | checkPortAvailability: (port, role) => { |
| 759 | probed.push({ port, role }); |
| 760 | return Effect.succeed(true); |
| 761 | }, |
| 762 | }); |
| 763 | |
| 764 | assert.deepStrictEqual(probed, [ |
| 765 | { port: 13_773, role: "server" }, |
| 766 | { port: 5733, role: "web" }, |
| 767 | ]); |
| 768 | }), |
| 769 | ); |
| 770 | }); |
| 771 | |
| 772 | describe("resolveModePortOffsets", () => { |
| 773 | it.effect("uses a shared fallback offset for dev mode", () => |
| 774 | Effect.gen(function* () { |
| 775 | const taken = new Set([13773, 5733]); |
| 776 | const offsets = yield* resolveModePortOffsets({ |
| 777 | mode: "dev", |
| 778 | startOffset: 0, |
| 779 | hasExplicitServerPort: false, |
| 780 | hasExplicitDevUrl: false, |
| 781 | checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), |
| 782 | }); |
| 783 | |
| 784 | assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 }); |
| 785 | }), |
| 786 | ); |
| 787 | |
| 788 | it.effect("keeps server offset stable for dev:web and only shifts web offset", () => |
| 789 | Effect.gen(function* () { |
| 790 | const taken = new Set([5733]); |
| 791 | const offsets = yield* resolveModePortOffsets({ |
| 792 | mode: "dev:web", |
| 793 | startOffset: 0, |
| 794 | hasExplicitServerPort: false, |
| 795 | hasExplicitDevUrl: false, |
| 796 | checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), |
| 797 | }); |
| 798 | |
| 799 | assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 1 }); |
| 800 | }), |
| 801 | ); |
| 802 | |
| 803 | it.effect("shifts only server offset for dev:server", () => |
| 804 | Effect.gen(function* () { |
| 805 | const taken = new Set([13773]); |
| 806 | const offsets = yield* resolveModePortOffsets({ |
| 807 | mode: "dev:server", |
| 808 | startOffset: 0, |
| 809 | hasExplicitServerPort: false, |
| 810 | hasExplicitDevUrl: false, |
| 811 | checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), |
| 812 | }); |
| 813 | |
| 814 | assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 }); |
| 815 | }), |
| 816 | ); |
| 817 | |
| 818 | it.effect("respects explicit dev-url override for dev:web", () => |
| 819 | Effect.gen(function* () { |
| 820 | const offsets = yield* resolveModePortOffsets({ |
| 821 | mode: "dev:web", |
| 822 | startOffset: 0, |
| 823 | hasExplicitServerPort: false, |
| 824 | hasExplicitDevUrl: true, |
| 825 | checkPortAvailability: () => Effect.succeed(false), |
| 826 | }); |
| 827 | |
| 828 | assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 }); |
| 829 | }), |
| 830 | ); |
| 831 | |
| 832 | it.effect("respects explicit server port override for dev:server", () => |
| 833 | Effect.gen(function* () { |
| 834 | const offsets = yield* resolveModePortOffsets({ |
| 835 | mode: "dev:server", |
| 836 | startOffset: 0, |
| 837 | hasExplicitServerPort: true, |
| 838 | hasExplicitDevUrl: false, |
| 839 | checkPortAvailability: () => Effect.succeed(false), |
| 840 | }); |
| 841 | |
| 842 | assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 }); |
| 843 | }), |
| 844 | ); |
| 845 | }); |
| 846 | |
| 847 | describe("runDevRunnerWithInput", () => { |
| 848 | it.effect("preserves invalid configuration as the exact cause", () => |
| 849 | Effect.gen(function* () { |
| 850 | const error = yield* runDevRunnerWithInput({ ...devServerInput, dryRun: true }).pipe( |
| 851 | Effect.provide( |
| 852 | Layer.merge( |
| 853 | netServiceLayer, |
| 854 | ConfigProvider.layer( |
| 855 | ConfigProvider.fromEnv({ env: { T3CODE_PORT_OFFSET: "not-an-integer" } }), |
| 856 | ), |
| 857 | ), |
| 858 | ), |
| 859 | Effect.flip, |
| 860 | ); |
| 861 | |
| 862 | if (error._tag !== "DevRunnerConfigurationError") { |
| 863 | assert.fail(`Unexpected error: ${error._tag}`); |
| 864 | } |
| 865 | assert.deepStrictEqual(error.configKeys, ["T3CODE_PORT_OFFSET", "T3CODE_DEV_INSTANCE"]); |
| 866 | assert.ok(error.cause !== undefined); |
| 867 | assert.ok(!error.message.includes(String((error.cause as Error).message))); |
| 868 | }), |
| 869 | ); |
| 870 | |
| 871 | it.effect("preserves process spawn context and the exact platform cause", () => { |
| 872 | const cause = PlatformError.systemError({ |
| 873 | _tag: "NotFound", |
| 874 | module: "ChildProcess", |
| 875 | method: "spawn", |
| 876 | description: "vp was not found", |
| 877 | }); |
| 878 | const spawnerLayer = Layer.succeed( |
| 879 | ChildProcessSpawner.ChildProcessSpawner, |
| 880 | ChildProcessSpawner.make(() => Effect.fail(cause)), |
| 881 | ); |
| 882 | |
| 883 | return Effect.gen(function* () { |
| 884 | const error = yield* runDevRunnerWithInput(devServerInput).pipe( |
| 885 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), |
| 886 | Effect.provideService(HostProcess.Platform, "linux"), |
| 887 | Effect.flip, |
| 888 | ); |
| 889 | |
| 890 | if (error._tag !== "DevRunnerProcessError") { |
| 891 | assert.fail(`Unexpected error: ${error._tag}`); |
| 892 | } |
| 893 | assert.equal(error.operation, "spawn"); |
| 894 | assert.equal(error.mode, "dev:server"); |
| 895 | assert.equal(error.executable, "vp"); |
| 896 | assert.equal(error.argumentCount, 5); |
| 897 | assert.equal(error.shell, false); |
| 898 | assert.equal(error.cause, cause); |
| 899 | assert.ok(!error.message.includes(cause.message)); |
| 900 | assert.notProperty(error, "args"); |
| 901 | assert.notInclude(error.message, "secret-token-value"); |
| 902 | }); |
| 903 | }); |
| 904 | |
| 905 | // `tailscale serve` config outlives the process, so a dry run that shared |
| 906 | // would replace and then tear down whatever mapping the port already had. |
| 907 | // Base-dir precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) |
| 908 | // lives in runDevRunnerWithInput; the env builder must not consult the |
| 909 | // ambient variable on its own, or it would silently outrank the worktree |
| 910 | // default and land dev state on the user's real database. |
| 911 | it.effect("ignores an ambient T3CODE_HOME when no home is resolved", () => |
| 912 | Effect.gen(function* () { |
| 913 | const env = yield* createDevRunnerEnv({ |
| 914 | mode: "dev", |
| 915 | baseEnv: { T3CODE_HOME: "/home/user/.t3" }, |
| 916 | serverOffset: 0, |
| 917 | webOffset: 0, |
| 918 | t3Home: undefined, |
| 919 | browser: undefined, |
| 920 | autoBootstrapProjectFromCwd: undefined, |
| 921 | logWebSocketEvents: undefined, |
| 922 | host: undefined, |
| 923 | port: undefined, |
| 924 | devUrl: undefined, |
| 925 | }); |
| 926 | |
| 927 | assert.equal(env.T3CODE_HOME, undefined); |
| 928 | }), |
| 929 | ); |
| 930 | |
| 931 | // Sharing dev:desktop would publish a URL whose renderer dials the |
| 932 | // visitor's own loopback, and would clobber the VITE_DEV_SERVER_URL that |
| 933 | // Electron loads from. It must decline, not half-work. |
| 934 | it.effect("declines to share for dev:desktop and still starts the stack", () => { |
| 935 | let spawnCount = 0; |
| 936 | const spawnerLayer = Layer.succeed( |
| 937 | ChildProcessSpawner.ChildProcessSpawner, |
| 938 | ChildProcessSpawner.make(() => { |
| 939 | spawnCount += 1; |
| 940 | return Effect.succeed(mockProcess(0)); |
| 941 | }), |
| 942 | ); |
| 943 | |
| 944 | return Effect.gen(function* () { |
| 945 | yield* runDevRunnerWithInput({ |
| 946 | ...devServerInput, |
| 947 | mode: "dev:desktop", |
| 948 | port: undefined, |
| 949 | share: true, |
| 950 | }).pipe( |
| 951 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), |
| 952 | Effect.provideService(HostProcess.Platform, "linux"), |
| 953 | ); |
| 954 | |
| 955 | assert.equal(spawnCount, 1); |
| 956 | }); |
| 957 | }); |
| 958 | |
| 959 | // Single-origin browser dev proxies the backend at localhost, so a backend |
| 960 | // bound only to a specific interface breaks every proxied request in a way |
| 961 | // that looks like a broken server. Reject the combination up front. |
| 962 | it.effect("rejects a specific non-loopback --host for browser dev modes", () => { |
| 963 | const spawnerLayer = Layer.succeed( |
| 964 | ChildProcessSpawner.ChildProcessSpawner, |
| 965 | ChildProcessSpawner.make(() => Effect.succeed(mockProcess(0))), |
| 966 | ); |
| 967 | |
| 968 | return Effect.gen(function* () { |
| 969 | const error = yield* runDevRunnerWithInput({ |
| 970 | ...devServerInput, |
| 971 | mode: "dev", |
| 972 | port: undefined, |
| 973 | host: "192.168.1.10", |
| 974 | }).pipe( |
| 975 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), |
| 976 | Effect.provideService(HostProcess.Platform, "linux"), |
| 977 | Effect.flip, |
| 978 | ); |
| 979 | |
| 980 | if (error._tag !== "DevRunnerHostNotProxiableError") { |
| 981 | assert.fail(`Unexpected error: ${error._tag}`); |
| 982 | } |
| 983 | assert.equal(error.mode, "dev"); |
| 984 | assert.equal(error.host, "192.168.1.10"); |
| 985 | assert.include(error.message, "0.0.0.0"); |
| 986 | assert.include(error.message, "--share"); |
| 987 | }); |
| 988 | }); |
| 989 | |
| 990 | // Wildcards keep loopback answering, so the proxy target stays valid and |
| 991 | // the combination must keep working — it is the documented way to serve a |
| 992 | // LAN interface and the browser proxy at once. |
| 993 | it.effect("still spawns the stack for a wildcard --host in dev mode", () => { |
| 994 | let spawnCount = 0; |
| 995 | const spawnerLayer = Layer.succeed( |
| 996 | ChildProcessSpawner.ChildProcessSpawner, |
| 997 | ChildProcessSpawner.make(() => { |
| 998 | spawnCount += 1; |
| 999 | return Effect.succeed(mockProcess(0)); |
| 1000 | }), |
| 1001 | ); |
| 1002 | |
| 1003 | return Effect.gen(function* () { |
| 1004 | yield* runDevRunnerWithInput({ |
| 1005 | ...devServerInput, |
| 1006 | mode: "dev", |
| 1007 | port: undefined, |
| 1008 | host: "0.0.0.0", |
| 1009 | }).pipe( |
| 1010 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), |
| 1011 | Effect.provideService(HostProcess.Platform, "linux"), |
| 1012 | ); |
| 1013 | |
| 1014 | assert.equal(spawnCount, 1); |
| 1015 | }); |
| 1016 | }); |
| 1017 | |
| 1018 | // dev:server does not proxy — the client talks to the backend directly — |
| 1019 | // so a specific interface bind stays legitimate there. |
| 1020 | it.effect("keeps a specific --host working for dev:server", () => { |
| 1021 | let spawnCount = 0; |
| 1022 | const spawnerLayer = Layer.succeed( |
| 1023 | ChildProcessSpawner.ChildProcessSpawner, |
| 1024 | ChildProcessSpawner.make(() => { |
| 1025 | spawnCount += 1; |
| 1026 | return Effect.succeed(mockProcess(0)); |
| 1027 | }), |
| 1028 | ); |
| 1029 | |
| 1030 | return Effect.gen(function* () { |
| 1031 | yield* runDevRunnerWithInput({ |
| 1032 | ...devServerInput, |
| 1033 | host: "192.168.1.10", |
| 1034 | }).pipe( |
| 1035 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), |
| 1036 | Effect.provideService(HostProcess.Platform, "linux"), |
| 1037 | ); |
| 1038 | |
| 1039 | assert.equal(spawnCount, 1); |
| 1040 | }); |
| 1041 | }); |
| 1042 | |
| 1043 | // A shared origin means a remote browser, where unbundled dev's |
| 1044 | // per-module waterfall pays a tailnet round trip per import level. The |
| 1045 | // runner defaults bundled dev on for the spawned stack, but only |
| 1046 | // defaults: an explicit T3CODE_BUNDLED_DEV (even "0") must pass through. |
| 1047 | describe("--share bundled dev default", () => { |
| 1048 | const shareSpawnedEnv = (input: { readonly ambientBundledDev: string | undefined }) => |
| 1049 | Effect.gen(function* () { |
| 1050 | let captured: Record<string, string | undefined> | undefined; |
| 1051 | const spawnerLayer = Layer.succeed( |
| 1052 | ChildProcessSpawner.ChildProcessSpawner, |
| 1053 | ChildProcessSpawner.make((command) => { |
| 1054 | const spawned = command as unknown as { |
| 1055 | readonly command: string; |
| 1056 | readonly args: ReadonlyArray<string>; |
| 1057 | readonly options?: { readonly env?: Record<string, string | undefined> }; |
| 1058 | }; |
| 1059 | if (spawned.command === "vp") { |
| 1060 | captured = spawned.options?.env; |
| 1061 | return Effect.succeed(mockProcess(0)); |
| 1062 | } |
| 1063 | // tailscale: answer `status --json` with a valid tailnet name, |
| 1064 | // succeed the `serve`/`off` calls. |
| 1065 | return Effect.succeed( |
| 1066 | ChildProcessSpawner.makeHandle({ |
| 1067 | pid: ChildProcessSpawner.ProcessId(2), |
| 1068 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), |
| 1069 | isRunning: Effect.succeed(false), |
| 1070 | kill: () => Effect.void, |
| 1071 | unref: Effect.succeed(Effect.void), |
| 1072 | stdin: Sink.drain, |
| 1073 | stdout: spawned.args.includes("status") |
| 1074 | ? Stream.make( |
| 1075 | new TextEncoder().encode( |
| 1076 | JSON.stringify({ Self: { DNSName: "host.example.ts.net." } }), |
| 1077 | ), |
| 1078 | ) |
| 1079 | : Stream.empty, |
| 1080 | stderr: Stream.empty, |
| 1081 | all: Stream.empty, |
| 1082 | getInputFd: () => Sink.drain, |
| 1083 | getOutputFd: () => Stream.empty, |
| 1084 | }), |
| 1085 | ); |
| 1086 | }), |
| 1087 | ); |
| 1088 | |
| 1089 | yield* runDevRunnerWithInput({ |
| 1090 | ...devServerInput, |
| 1091 | mode: "dev", |
| 1092 | port: undefined, |
| 1093 | share: true, |
| 1094 | }).pipe( |
| 1095 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), |
| 1096 | Effect.provideService(HostProcess.Platform, "linux"), |
| 1097 | Effect.provideService( |
| 1098 | HostProcess.Environment, |
| 1099 | input.ambientBundledDev === undefined |
| 1100 | ? {} |
| 1101 | : { T3CODE_BUNDLED_DEV: input.ambientBundledDev }, |
| 1102 | ), |
| 1103 | ); |
| 1104 | |
| 1105 | return captured; |
| 1106 | }); |
| 1107 | |
| 1108 | it.effect("defaults T3CODE_BUNDLED_DEV=1 for a shared run", () => |
| 1109 | Effect.gen(function* () { |
| 1110 | const env = yield* shareSpawnedEnv({ ambientBundledDev: undefined }); |
| 1111 | assert.equal(env?.T3CODE_BUNDLED_DEV, "1"); |
| 1112 | }), |
| 1113 | ); |
| 1114 | |
| 1115 | it.effect("keeps an explicit T3CODE_BUNDLED_DEV=0 opt-out", () => |
| 1116 | Effect.gen(function* () { |
| 1117 | const env = yield* shareSpawnedEnv({ ambientBundledDev: "0" }); |
| 1118 | assert.equal(env?.T3CODE_BUNDLED_DEV, "0"); |
| 1119 | }), |
| 1120 | ); |
| 1121 | |
| 1122 | it.effect("leaves T3CODE_BUNDLED_DEV unset without --share", () => |
| 1123 | Effect.gen(function* () { |
| 1124 | let captured: Record<string, string | undefined> | undefined; |
| 1125 | const spawnerLayer = Layer.succeed( |
| 1126 | ChildProcessSpawner.ChildProcessSpawner, |
| 1127 | ChildProcessSpawner.make((command) => { |
| 1128 | captured = ( |
| 1129 | command as { |
| 1130 | readonly options?: { readonly env?: Record<string, string | undefined> }; |
| 1131 | } |
| 1132 | ).options?.env; |
| 1133 | return Effect.succeed(mockProcess(0)); |
| 1134 | }), |
| 1135 | ); |
| 1136 | |
| 1137 | yield* runDevRunnerWithInput({ |
| 1138 | ...devServerInput, |
| 1139 | mode: "dev", |
| 1140 | port: undefined, |
| 1141 | }).pipe( |
| 1142 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), |
| 1143 | Effect.provideService(HostProcess.Platform, "linux"), |
| 1144 | Effect.provideService(HostProcess.Environment, {}), |
| 1145 | ); |
| 1146 | |
| 1147 | assert.equal(captured?.T3CODE_BUNDLED_DEV, undefined); |
| 1148 | }), |
| 1149 | ); |
| 1150 | }); |
| 1151 | |
| 1152 | it.effect("spawns nothing when --dry-run is combined with --share", () => { |
| 1153 | let spawnCount = 0; |
| 1154 | const spawnerLayer = Layer.succeed( |
| 1155 | ChildProcessSpawner.ChildProcessSpawner, |
| 1156 | ChildProcessSpawner.make(() => { |
| 1157 | spawnCount += 1; |
| 1158 | return Effect.succeed(mockProcess(0)); |
| 1159 | }), |
| 1160 | ); |
| 1161 | |
| 1162 | return Effect.gen(function* () { |
| 1163 | yield* runDevRunnerWithInput({ |
| 1164 | ...devServerInput, |
| 1165 | mode: "dev", |
| 1166 | port: undefined, |
| 1167 | dryRun: true, |
| 1168 | share: true, |
| 1169 | }).pipe( |
| 1170 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), |
| 1171 | Effect.provideService(HostProcess.Platform, "linux"), |
| 1172 | ); |
| 1173 | |
| 1174 | assert.equal(spawnCount, 0); |
| 1175 | }); |
| 1176 | }); |
| 1177 | |
| 1178 | it.effect("reports non-zero exits without manufacturing a cause", () => { |
| 1179 | const spawnerLayer = Layer.succeed( |
| 1180 | ChildProcessSpawner.ChildProcessSpawner, |
| 1181 | ChildProcessSpawner.make(() => Effect.succeed(mockProcess(17))), |
| 1182 | ); |
| 1183 | |
| 1184 | return Effect.gen(function* () { |
| 1185 | const error = yield* runDevRunnerWithInput(devServerInput).pipe( |
| 1186 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), |
| 1187 | Effect.provideService(HostProcess.Platform, "linux"), |
| 1188 | Effect.flip, |
| 1189 | ); |
| 1190 | |
| 1191 | if (error._tag !== "DevRunnerProcessExitError") { |
| 1192 | assert.fail(`Unexpected error: ${error._tag}`); |
| 1193 | } |
| 1194 | assert.equal(error.mode, "dev:server"); |
| 1195 | assert.equal(error.executable, "vp"); |
| 1196 | assert.equal(error.argumentCount, 5); |
| 1197 | assert.equal(error.shell, false); |
| 1198 | assert.equal(error.exitCode, 17); |
| 1199 | assert.ok(!("cause" in error)); |
| 1200 | assert.notProperty(error, "args"); |
| 1201 | assert.notInclude(error.message, "secret-token-value"); |
| 1202 | }); |
| 1203 | }); |
| 1204 | |
| 1205 | it.effect("preserves wait-for-exit failures as the exact cause", () => { |
| 1206 | const cause = PlatformError.systemError({ |
| 1207 | _tag: "Unknown", |
| 1208 | module: "ChildProcess", |
| 1209 | method: "exitCode", |
| 1210 | description: "process status became unavailable", |
| 1211 | }); |
| 1212 | const spawnerLayer = Layer.succeed( |
| 1213 | ChildProcessSpawner.ChildProcessSpawner, |
| 1214 | ChildProcessSpawner.make(() => Effect.succeed(mockProcess(cause))), |
| 1215 | ); |
| 1216 | |
| 1217 | return Effect.gen(function* () { |
| 1218 | const error = yield* runDevRunnerWithInput(devServerInput).pipe( |
| 1219 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), |
| 1220 | Effect.provideService(HostProcess.Platform, "linux"), |
| 1221 | Effect.flip, |
| 1222 | ); |
| 1223 | |
| 1224 | if (error._tag !== "DevRunnerProcessError") { |
| 1225 | assert.fail(`Unexpected error: ${error._tag}`); |
| 1226 | } |
| 1227 | assert.equal(error.operation, "wait-for-exit"); |
| 1228 | assert.equal(error.mode, "dev:server"); |
| 1229 | assert.equal(error.executable, "vp"); |
| 1230 | assert.equal(error.argumentCount, 5); |
| 1231 | assert.equal(error.shell, false); |
| 1232 | assert.equal(error.cause, cause); |
| 1233 | assert.ok(!error.message.includes(cause.message)); |
| 1234 | assert.notProperty(error, "args"); |
| 1235 | assert.notInclude(error.message, "secret-token-value"); |
| 1236 | }); |
| 1237 | }); |
| 1238 | |
| 1239 | describe("t3 home precedence", () => { |
| 1240 | const makeWorktree = Effect.acquireRelease( |
| 1241 | Effect.sync(() => { |
| 1242 | const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-devrunner-")); |
| 1243 | NodeFS.writeFileSync( |
| 1244 | NodePath.join(root, ".git"), |
| 1245 | "gitdir: /elsewhere/.git/worktrees/x\n", |
| 1246 | ); |
| 1247 | return root; |
| 1248 | }), |
| 1249 | (root) => Effect.sync(() => NodeFS.rmSync(root, { recursive: true, force: true })), |
| 1250 | ); |
| 1251 | |
| 1252 | const spawnedHome = (input: { |
| 1253 | readonly t3Home: string | undefined; |
| 1254 | readonly cwd: string; |
| 1255 | readonly ambientHome: string | undefined; |
| 1256 | }) => |
| 1257 | Effect.gen(function* () { |
| 1258 | let captured: Record<string, string | undefined> | undefined; |
| 1259 | const spawnerLayer = Layer.succeed( |
| 1260 | ChildProcessSpawner.ChildProcessSpawner, |
| 1261 | ChildProcessSpawner.make((command) => { |
| 1262 | captured = ( |
| 1263 | command as { |
| 1264 | readonly options?: { readonly env?: Record<string, string | undefined> }; |
| 1265 | } |
| 1266 | ).options?.env; |
| 1267 | return Effect.succeed(mockProcess(0)); |
| 1268 | }), |
| 1269 | ); |
| 1270 | |
| 1271 | yield* runDevRunnerWithInput({ ...devServerInput, t3Home: input.t3Home }).pipe( |
| 1272 | Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), |
| 1273 | Effect.provideService(HostProcess.Platform, "linux"), |
| 1274 | Effect.provideService(HostProcess.WorkingDirectory, input.cwd), |
| 1275 | Effect.provideService( |
| 1276 | HostProcess.Environment, |
| 1277 | input.ambientHome === undefined ? {} : { T3CODE_HOME: input.ambientHome }, |
| 1278 | ), |
| 1279 | ); |
| 1280 | |
| 1281 | return captured?.T3CODE_HOME; |
| 1282 | }); |
| 1283 | |
| 1284 | it.effect("prefers an explicit --home-dir over the worktree default", () => |
| 1285 | Effect.gen(function* () { |
| 1286 | const path = yield* Path.Path; |
| 1287 | const root = yield* makeWorktree; |
| 1288 | const home = yield* spawnedHome({ |
| 1289 | t3Home: "/tmp/explicit-home", |
| 1290 | cwd: root, |
| 1291 | ambientHome: "/home/user/.t3", |
| 1292 | }); |
| 1293 | assert.equal(home, path.resolve("/tmp/explicit-home")); |
| 1294 | }).pipe(Effect.scoped), |
| 1295 | ); |
| 1296 | |
| 1297 | it.effect("treats a blank --home-dir as unset rather than as a selection", () => |
| 1298 | Effect.gen(function* () { |
| 1299 | const path = yield* Path.Path; |
| 1300 | const root = yield* makeWorktree; |
| 1301 | const home = yield* spawnedHome({ |
| 1302 | t3Home: " ", |
| 1303 | cwd: root, |
| 1304 | ambientHome: "/home/user/.t3", |
| 1305 | }); |
| 1306 | assert.equal(home, path.join(path.resolve(root), ".t3")); |
| 1307 | }).pipe(Effect.scoped), |
| 1308 | ); |
| 1309 | |
| 1310 | it.effect("prefers the worktree .t3 over an ambient T3CODE_HOME", () => |
| 1311 | Effect.gen(function* () { |
| 1312 | const path = yield* Path.Path; |
| 1313 | const root = yield* makeWorktree; |
| 1314 | const home = yield* spawnedHome({ |
| 1315 | t3Home: undefined, |
| 1316 | cwd: root, |
| 1317 | ambientHome: "/home/user/.t3", |
| 1318 | }); |
| 1319 | assert.equal(home, path.join(path.resolve(root), ".t3")); |
| 1320 | }).pipe(Effect.scoped), |
| 1321 | ); |
| 1322 | |
| 1323 | it.effect("falls back to an ambient T3CODE_HOME outside a worktree", () => |
| 1324 | Effect.gen(function* () { |
| 1325 | const path = yield* Path.Path; |
| 1326 | const home = yield* spawnedHome({ |
| 1327 | t3Home: undefined, |
| 1328 | cwd: NodeOS.tmpdir(), |
| 1329 | ambientHome: "/home/user/.t3", |
| 1330 | }); |
| 1331 | assert.equal(home, path.resolve("/home/user/.t3")); |
| 1332 | }), |
| 1333 | ); |
| 1334 | |
| 1335 | it.effect("leaves the home implicit with no worktree and no ambient value", () => |
| 1336 | Effect.gen(function* () { |
| 1337 | const home = yield* spawnedHome({ |
| 1338 | t3Home: undefined, |
| 1339 | cwd: NodeOS.tmpdir(), |
| 1340 | ambientHome: undefined, |
| 1341 | }); |
| 1342 | assert.equal(home, undefined); |
| 1343 | }), |
| 1344 | ); |
| 1345 | }); |
| 1346 | }); |
| 1347 | }); |