1// @effect-diagnostics nodeBuiltinImport:off - builds real worktree layouts on disk.
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import * as NetService from "@t3tools/shared/Net";
7import * as HostProcess from "@t3tools/shared/HostProcess";
8import { assert, describe, it } from "@effect/vitest";
9import * as ConfigProvider from "effect/ConfigProvider";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13import * as PlatformError from "effect/PlatformError";
14import * as Sink from "effect/Sink";
15import * as Stream from "effect/Stream";
16import { ChildProcess, ChildProcessSpawner } from "effect/process";
17
18import {
19 checkPortAvailabilityOnHosts,
20 createDevRunnerEnv,
21 devPortProbeHosts,
22 findFirstAvailableOffset,
23 getDevRunnerModeArgs,
24 isBrowserAllowedPort,
25 resolveModePortOffsets,
26 resolveOffset,
27 runDevRunnerWithInput,
28} from "./dev-runner.ts";
29
30const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }));
31const 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
39function 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
58const 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
72it.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});