Skip to content
Source
Sign inSign up

dev-runner.test.ts

863 lines31.5 KB

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 ByteSize from "effect/ByteSize";
10import * as ConfigProvider from "effect/ConfigProvider";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Logger from "effect/Logger";
15import * as Path from "effect/Path";
16import * as PlatformError from "effect/PlatformError";
17import * as Sink from "effect/Sink";
18import * as Stream from "effect/Stream";
19import { ChildProcessSpawner } from "effect/process";
20​
21import {
22 checkPortAvailabilityOnHosts,
23 createDevRunnerEnv,
24 findFirstAvailableOffset,
25 resolveModePortOffsets,
26 runDevRunnerWithInput,
27} from "./dev-runner.ts";
28​
29const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }));
30const netServiceLayer = Layer.succeed(NetService.NetService, {
31 canListenOnHost: () => Effect.succeed(true),
32 isPortAvailableOnLoopback: () => Effect.succeed(true),
33 hasListenerOnHost: () => Effect.succeed(false),
34 reserveLoopbackPort: () => Effect.succeed(49_152),
35 findAvailablePort: (port) => Effect.succeed(port),
36});
37​
38function mockProcess(exit: number | PlatformError.PlatformError) {
39 return ChildProcessSpawner.makeHandle({
40 pid: ChildProcessSpawner.ProcessId(1),
41 exitCode:
42 typeof exit === "number"
43 ? Effect.succeed(ChildProcessSpawner.ExitCode(exit))
44 : Effect.fail(exit),
45 isRunning: Effect.succeed(false),
46 kill: () => Effect.void,
47 unref: Effect.succeed(Effect.void),
48 stdin: Sink.drain,
49 stdout: Stream.empty,
50 stderr: Stream.empty,
51 all: Stream.empty,
52 getInputFd: () => Sink.drain,
53 getOutputFd: () => Stream.empty,
54 });
56​
57const devServerInput = {
58 mode: "dev:server",
59 t3Home: "/tmp/t3code-dev-runner",
60 browser: undefined,
61 autoBootstrapProjectFromCwd: undefined,
62 logWebSocketEvents: undefined,
63 host: undefined,
64 port: 13_773,
65 devUrl: undefined,
66 dryRun: false,
67 share: false,
68 built: false,
69 runArgs: ["--inspect", "secret-token-value"],
70} as const;
71​
72it.layer(NodeServices.layer)("dev-runner", (it) => {
73 describe("createDevRunnerEnv", () => {
74 it.effect("forwards the reusable auth token to web dev and removes it for desktop", () =>
75 Effect.gen(function* () {
76 const input = {
77 baseEnv: { T3CODE_DEV_AUTH_TOKEN: "reusable-dev-auth-token-that-is-long-enough" },
78 serverOffset: 0,
79 webOffset: 0,
80 t3Home: undefined,
81 browser: undefined,
82 autoBootstrapProjectFromCwd: undefined,
83 logWebSocketEvents: undefined,
84 host: undefined,
85 port: undefined,
86 devUrl: undefined,
87 } as const;
88 const web = yield* createDevRunnerEnv({ ...input, mode: "dev" });
89 const desktop = yield* createDevRunnerEnv({ ...input, mode: "dev:desktop" });
90​
91 assert.equal(web.T3CODE_DEV_AUTH_TOKEN, input.baseEnv.T3CODE_DEV_AUTH_TOKEN);
92 assert.equal(desktop.T3CODE_DEV_AUTH_TOKEN, undefined);
93 }),
94 );
95​
96 it.effect("turns product analytics off unless the developer opts in", () =>
97 Effect.gen(function* () {
98 const input = {
99 mode: "dev",
100 serverOffset: 0,
101 webOffset: 0,
102 t3Home: undefined,
103 browser: undefined,
104 autoBootstrapProjectFromCwd: undefined,
105 logWebSocketEvents: undefined,
106 host: undefined,
107 port: undefined,
108 devUrl: undefined,
109 } as const;
110 const byDefault = yield* createDevRunnerEnv({ ...input, baseEnv: {} });
111 const optedIn = yield* createDevRunnerEnv({
112 ...input,
113 baseEnv: { T3CODE_TELEMETRY_ENABLED: "true" },
114 });
115​
116 assert.equal(byDefault.T3CODE_TELEMETRY_ENABLED, "false");
117 assert.equal(optedIn.T3CODE_TELEMETRY_ENABLED, "true");
118 }),
119 );
120​
121 it.effect("strips inherited service-launcher context", () =>
122 Effect.gen(function* () {
123 const env = yield* createDevRunnerEnv({
124 mode: "dev",
125 baseEnv: {
126 T3_SERVICE_LAUNCHER_CONTEXT: '{"childVersion":"9.9.9"}',
127 T3_BOOT_SERVICE_UNIT: "t3code.service",
128 },
129 serverOffset: 0,
130 webOffset: 0,
131 t3Home: undefined,
132 browser: undefined,
133 autoBootstrapProjectFromCwd: undefined,
134 logWebSocketEvents: undefined,
135 host: undefined,
136 port: undefined,
137 devUrl: undefined,
138 });
139​
140 assert.equal(env.T3_SERVICE_LAUNCHER_CONTEXT, undefined);
141 assert.equal(env.T3_BOOT_SERVICE_UNIT, undefined);
142 }),
143 );
144​
145 it.effect.each(["dev", "dev:web"] as const)(
146 "leaves the client backend URLs unset in %s mode",
147 (mode) =>
148 Effect.gen(function* () {
149 const env = yield* createDevRunnerEnv({
150 mode,
151 baseEnv: {
152 VITE_HTTP_URL: "http://localhost:1234",
153 VITE_WS_URL: "ws://localhost:1234",
154 },
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 });
165​
166 assert.equal(env.VITE_HTTP_URL, undefined);
167 assert.equal(env.VITE_WS_URL, undefined);
168 assert.equal(env.T3CODE_PORT, "13773");
169 assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, "1");
170 }),
171 );
172​
173 it.effect.each(["dev", "dev:web"] as const)("drops an inherited HOST in %s mode", (mode) =>
174 Effect.gen(function* () {
175 const env = yield* createDevRunnerEnv({
176 mode,
177 baseEnv: { HOST: "0.0.0.0" },
178 serverOffset: 0,
179 webOffset: 0,
180 t3Home: undefined,
181 browser: undefined,
182 autoBootstrapProjectFromCwd: undefined,
183 logWebSocketEvents: undefined,
184 host: undefined,
185 port: undefined,
186 devUrl: undefined,
187 });
188​
189 assert.equal(env.HOST, undefined);
190 }),
191 );
192 });
193​
194 describe("findFirstAvailableOffset", () => {
195 it.effect("advances until all required ports are available", () =>
196 Effect.gen(function* () {
197 const taken = new Set([13773, 5733, 13774, 5734]);
198 const offset = yield* findFirstAvailableOffset({
199 startOffset: 0,
200 requireServerPort: true,
201 requireWebPort: true,
202 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
203 });
204​
205 assert.equal(offset, 2);
206 }),
207 );
208​
209 it.effect("skips browser-blocked web ports before probing availability", () =>
210 Effect.gen(function* () {
211 const probed: Array<{ port: number; role: string | undefined }> = [];
212 const offset = yield* findFirstAvailableOffset({
213 // 5733 + 833 = 6566, which browsers block as sane-port.
214 startOffset: 833,
215 requireServerPort: true,
216 requireWebPort: true,
217 checkPortAvailability: (port, role) => {
218 probed.push({ port, role });
219 return Effect.succeed(true);
220 },
221 });
222​
223 assert.equal(offset, 834);
224 assert.deepStrictEqual(probed, [
225 { port: 14_607, role: "server" },
226 { port: 6567, role: "web" },
227 ]);
228 }),
229 );
230​
231 it.effect("does not reject a server-only offset because its unused web port is blocked", () =>
232 Effect.gen(function* () {
233 const offset = yield* findFirstAvailableOffset({
234 startOffset: 833,
235 requireServerPort: true,
236 requireWebPort: false,
237 checkPortAvailability: () => Effect.succeed(true),
238 });
239​
240 assert.equal(offset, 833);
241 }),
242 );
243 });
244​
245 describe("checkPortAvailabilityOnHosts", () => {
246 it.effect("checks overlapping hosts sequentially to avoid self-interference", () =>
247 Effect.gen(function* () {
248 let inFlightCount = 0;
249 const calls: Array<[number, string]> = [];
250​
251 const available = yield* checkPortAvailabilityOnHosts(
252 13_773,
253 ["127.0.0.1", "0.0.0.0", "::"],
254 (port, host) =>
255 Effect.promise(async () => {
256 calls.push([port, host]);
257 inFlightCount += 1;
258 const overlapped = inFlightCount > 1;
259 await Promise.resolve();
260 inFlightCount -= 1;
261 return !overlapped;
262 }),
263 );
264​
265 assert.equal(available, true);
266 assert.deepStrictEqual(calls, [
267 [13_773, "127.0.0.1"],
268 [13_773, "0.0.0.0"],
269 [13_773, "::"],
270 ]);
271 }),
272 );
273 });
274​
275 describe("devPortProbeHosts", () => {
276 it.effect("passes the port role so only the server port sees the bind host", () =>
277 Effect.gen(function* () {
278 const probed: Array<{ port: number; role: string | undefined }> = [];
279​
280 yield* resolveModePortOffsets({
281 mode: "dev",
282 startOffset: 0,
283 hasExplicitServerPort: false,
284 hasExplicitDevUrl: false,
285 checkPortAvailability: (port, role) => {
286 probed.push({ port, role });
287 return Effect.succeed(true);
288 },
289 });
290​
291 assert.deepStrictEqual(probed, [
292 { port: 13_773, role: "server" },
293 { port: 5733, role: "web" },
294 ]);
295 }),
296 );
297 });
298​
299 describe("resolveModePortOffsets", () => {
300 it.effect("uses a shared fallback offset for dev mode", () =>
301 Effect.gen(function* () {
302 const taken = new Set([13773, 5733]);
303 const offsets = yield* resolveModePortOffsets({
304 mode: "dev",
305 startOffset: 0,
306 hasExplicitServerPort: false,
307 hasExplicitDevUrl: false,
308 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
309 });
310​
311 assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 });
312 }),
313 );
314​
315 it.effect("keeps server offset stable for dev:web and only shifts web offset", () =>
316 Effect.gen(function* () {
317 const taken = new Set([5733]);
318 const offsets = yield* resolveModePortOffsets({
319 mode: "dev:web",
320 startOffset: 0,
321 hasExplicitServerPort: false,
322 hasExplicitDevUrl: false,
323 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
324 });
325​
326 assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 1 });
327 }),
328 );
329​
330 it.effect("shifts only server offset for dev:server", () =>
331 Effect.gen(function* () {
332 const taken = new Set([13773]);
333 const offsets = yield* resolveModePortOffsets({
334 mode: "dev:server",
335 startOffset: 0,
336 hasExplicitServerPort: false,
337 hasExplicitDevUrl: false,
338 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
339 });
340​
341 assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 });
342 }),
343 );
344​
345 it.effect("respects explicit dev-url override for dev:web", () =>
346 Effect.gen(function* () {
347 const offsets = yield* resolveModePortOffsets({
348 mode: "dev:web",
349 startOffset: 0,
350 hasExplicitServerPort: false,
351 hasExplicitDevUrl: true,
352 checkPortAvailability: () => Effect.succeed(false),
353 });
354​
355 assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 });
356 }),
357 );
358 });
359​
360 describe("runDevRunnerWithInput", () => {
361 it.effect("preserves process spawn context and the exact platform cause", () => {
362 const cause = PlatformError.systemError({
363 _tag: "NotFound",
364 module: "ChildProcess",
365 method: "spawn",
366 description: "vp was not found",
367 });
368 const spawnerLayer = Layer.succeed(
369 ChildProcessSpawner.ChildProcessSpawner,
370 ChildProcessSpawner.make(() => Effect.fail(cause)),
371 );
372​
373 return Effect.gen(function* () {
374 const error = yield* runDevRunnerWithInput(devServerInput).pipe(
375 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
376 Effect.provideService(HostProcess.Platform, "linux"),
377 Effect.flip,
378 );
379​
380 if (error._tag !== "DevRunnerProcessError") {
381 assert.fail(`Unexpected error: ${error._tag}`);
382 }
383 assert.equal(error.operation, "spawn");
384 assert.equal(error.mode, "dev:server");
385 assert.equal(error.executable, "vp");
386 assert.equal(error.argumentCount, 5);
387 assert.equal(error.shell, false);
388 assert.equal(error.cause, cause);
389 assert.ok(!error.message.includes(cause.message));
390 assert.notProperty(error, "args");
391 assert.notInclude(error.message, "secret-token-value");
392 });
393 });
394​
395 it.effect("ignores an ambient T3CODE_HOME when no home is resolved", () =>
396 Effect.gen(function* () {
397 const env = yield* createDevRunnerEnv({
398 mode: "dev",
399 baseEnv: { T3CODE_HOME: "/home/user/.t3" },
400 serverOffset: 0,
401 webOffset: 0,
402 t3Home: undefined,
403 browser: undefined,
404 autoBootstrapProjectFromCwd: undefined,
405 logWebSocketEvents: undefined,
406 host: undefined,
407 port: undefined,
408 devUrl: undefined,
409 });
410​
411 assert.equal(env.T3CODE_HOME, undefined);
412 }),
413 );
414​
415 it.effect("expands a home-relative T3 home the way the server does", () =>
416 Effect.gen(function* () {
417 const path = yield* Path.Path;
418 const homeFor = (t3Home: string) =>
419 createDevRunnerEnv({
420 mode: "dev",
421 baseEnv: {},
422 serverOffset: 0,
423 webOffset: 0,
424 t3Home,
425 browser: undefined,
426 autoBootstrapProjectFromCwd: undefined,
427 logWebSocketEvents: undefined,
428 host: undefined,
429 port: undefined,
430 devUrl: undefined,
431 }).pipe(
432 Effect.provideService(HostProcess.HomeDirectory, "/home/dev"),
433 Effect.map((env) => env.T3CODE_HOME),
434 );
435​
436 assert.equal(yield* homeFor("~/t3-dev"), path.join("/home/dev", "t3-dev"));
437 assert.equal(yield* homeFor("~"), "/home/dev");
438 assert.equal(yield* homeFor("~\\t3-dev"), path.join("/home/dev", "t3-dev"));
439 assert.equal(yield* homeFor(" ~/t3-dev "), path.join("/home/dev", "t3-dev"));
440 assert.equal(yield* homeFor("~someone/t3-dev"), path.resolve("~someone/t3-dev"));
441 assert.equal(yield* homeFor("relative/t3-dev"), path.resolve("relative/t3-dev"));
442 assert.equal(yield* homeFor("/tmp/t3-dev"), path.resolve("/tmp/t3-dev"));
443 }),
444 );
445​
446 it.effect("rejects a specific non-loopback --host for browser dev modes", () => {
447 const spawnerLayer = Layer.succeed(
448 ChildProcessSpawner.ChildProcessSpawner,
449 ChildProcessSpawner.make(() => Effect.succeed(mockProcess(0))),
450 );
451​
452 return Effect.gen(function* () {
453 const error = yield* runDevRunnerWithInput({
454 ...devServerInput,
455 mode: "dev",
456 port: undefined,
457 host: "192.168.1.10",
458 }).pipe(
459 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
460 Effect.provideService(HostProcess.Platform, "linux"),
461 Effect.flip,
462 );
463​
464 if (error._tag !== "DevRunnerHostNotProxiableError") {
465 assert.fail(`Unexpected error: ${error._tag}`);
466 }
467 assert.equal(error.mode, "dev");
468 assert.equal(error.host, "192.168.1.10");
469 assert.include(error.message, "0.0.0.0");
470 assert.include(error.message, "--share");
471 });
472 });
473​
474 // A shared origin means a remote browser, where unbundled dev's
475 // per-module waterfall pays a tailnet round trip per import level. The
476 // runner defaults bundled dev on for the spawned stack, but only
477 // defaults: an explicit T3CODE_BUNDLED_DEV (even "0") must pass through.
478 describe("--share spawn wiring", () => {
479 const shareSpawned = (input: {
480 readonly ambientBundledDev?: string | undefined;
481 readonly built?: boolean;
482 readonly host?: string;
483 }) =>
484 Effect.gen(function* () {
485 let captured: Record<string, string | undefined> | undefined;
486 let vpArgs: ReadonlyArray<string> = [];
487 const tailscaleCalls: Array<ReadonlyArray<string>> = [];
488 const spawnerLayer = Layer.succeed(
489 ChildProcessSpawner.ChildProcessSpawner,
490 ChildProcessSpawner.make((command) => {
491 const spawned = command as unknown as {
492 readonly command: string;
493 readonly args: ReadonlyArray<string>;
494 readonly options?: { readonly env?: Record<string, string | undefined> };
495 };
496 if (spawned.command === "vp") {
497 captured = spawned.options?.env;
498 vpArgs = spawned.args;
499 return Effect.succeed(mockProcess(0));
500 }
501 tailscaleCalls.push(spawned.args);
502 return Effect.succeed(
503 ChildProcessSpawner.makeHandle({
504 pid: ChildProcessSpawner.ProcessId(2),
505 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
506 isRunning: Effect.succeed(false),
507 kill: () => Effect.void,
508 unref: Effect.succeed(Effect.void),
509 stdin: Sink.drain,
510 stdout: spawned.args.includes("status")
511 ? Stream.make(
512 new TextEncoder().encode(
513 JSON.stringify({ Self: { DNSName: "host.example.ts.net." } }),
514 ),
515 )
516 : Stream.empty,
517 stderr: Stream.empty,
518 all: Stream.empty,
519 getInputFd: () => Sink.drain,
520 getOutputFd: () => Stream.empty,
521 }),
522 );
523 }),
524 );
525​
526 yield* runDevRunnerWithInput({
527 ...devServerInput,
528 mode: "dev",
529 port: undefined,
530 share: true,
531 built: input.built ?? false,
532 host: input.host,
533 }).pipe(
534 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
535 Effect.provideService(HostProcess.Platform, "linux"),
536 Effect.provideService(
537 HostProcess.Environment,
538 input.ambientBundledDev === undefined
539 ? {}
540 : { T3CODE_BUNDLED_DEV: input.ambientBundledDev },
541 ),
542 );
543​
544 return { env: captured, vpArgs, tailscaleCalls };
545 });
546​
547 it.effect("keeps an explicit T3CODE_BUNDLED_DEV=0 opt-out", () =>
548 Effect.gen(function* () {
549 const { env } = yield* shareSpawned({ ambientBundledDev: "0" });
550 assert.equal(env?.T3CODE_BUNDLED_DEV, "0");
551 }),
552 );
553​
554 it.effect("--built serves the backend on the web port's tailnet URL", () =>
555 Effect.gen(function* () {
556 const { env, vpArgs, tailscaleCalls } = yield* shareSpawned({ built: true });
557 assert.include(vpArgs, "dev:built");
558 assert.equal(env?.T3CODE_BUILT_DEV, "1");
559 assert.equal(env?.T3CODE_BUNDLED_DEV, undefined);
560 assert.equal(env?.VITE_DEV_SERVER_URL, `https://host.example.ts.net:${env?.PORT}/`);
561 const serve = tailscaleCalls.find((args) => args.includes("--bg"));
562 assert.deepEqual(serve?.slice(-2), [
563 `--https=${env?.PORT}`,
564 `http://127.0.0.1:${env?.T3CODE_PORT}`,
565 ]);
566 }),
567 );
568​
569 it.effect("--built --host ::1 targets the backend's IPv6 loopback", () =>
570 Effect.gen(function* () {
571 const { env, tailscaleCalls } = yield* shareSpawned({ built: true, host: "::1" });
572 const serve = tailscaleCalls.find((args) => args.includes("--bg"));
573 assert.equal(serve?.at(-1), `http://[::1]:${env?.T3CODE_PORT}`);
574 }),
575 );
576​
577 it.effect("without --built keeps Vite behind the tailnet URL", () =>
578 Effect.gen(function* () {
579 const { env, vpArgs, tailscaleCalls } = yield* shareSpawned({});
580 assert.include(vpArgs, "dev");
581 assert.notInclude(vpArgs, "dev:built");
582 assert.equal(env?.T3CODE_BUNDLED_DEV, "1");
583 const serve = tailscaleCalls.find((args) => args.includes("--bg"));
584 assert.equal(serve?.at(-1), `http://localhost:${env?.PORT}`);
585 }),
586 );
587 });
588​
589 it.effect.each([
590 { mode: "dev", share: false },
591 { mode: "dev:web", share: true },
592 { mode: "dev:desktop", share: true },
593 ] as const)("rejects --built for $mode with share=$share", ({ mode, share }) => {
594 const spawnerLayer = Layer.succeed(
595 ChildProcessSpawner.ChildProcessSpawner,
596 ChildProcessSpawner.make(() => Effect.succeed(mockProcess(0))),
597 );
598​
599 return Effect.gen(function* () {
600 const error = yield* runDevRunnerWithInput({
601 ...devServerInput,
602 mode,
603 port: undefined,
604 share,
605 built: true,
606 }).pipe(
607 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
608 Effect.provideService(HostProcess.Platform, "linux"),
609 Effect.flip,
610 );
611​
612 assert.equal(error._tag, "DevRunnerBuiltNeedsShareError");
613 });
614 });
615​
616 it.effect("reports non-zero exits without manufacturing a cause", () => {
617 const spawnerLayer = Layer.succeed(
618 ChildProcessSpawner.ChildProcessSpawner,
619 ChildProcessSpawner.make(() => Effect.succeed(mockProcess(17))),
620 );
621​
622 return Effect.gen(function* () {
623 const error = yield* runDevRunnerWithInput(devServerInput).pipe(
624 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
625 Effect.provideService(HostProcess.Platform, "linux"),
626 Effect.flip,
627 );
628​
629 if (error._tag !== "DevRunnerProcessExitError") {
630 assert.fail(`Unexpected error: ${error._tag}`);
631 }
632 assert.equal(error.mode, "dev:server");
633 assert.equal(error.executable, "vp");
634 assert.equal(error.argumentCount, 5);
635 assert.equal(error.shell, false);
636 assert.equal(error.exitCode, 17);
637 assert.ok(!("cause" in error));
638 assert.notProperty(error, "args");
639 assert.notInclude(error.message, "secret-token-value");
640 });
641 });
642​
643 describe("t3 home precedence", () => {
644 const makeWorktree = Effect.acquireRelease(
645 Effect.sync(() => {
646 const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-devrunner-"));
647 NodeFS.writeFileSync(
648 NodePath.join(root, ".git"),
649 "gitdir: /elsewhere/.git/worktrees/x\n",
650 );
651 return root;
652 }),
653 (root) => Effect.sync(() => NodeFS.rmSync(root, { recursive: true, force: true })),
654 );
655​
656 const spawnedHome = (input: {
657 readonly t3Home: string | undefined;
658 readonly cwd: string;
659 readonly ambientHome: string | undefined;
660 }) =>
661 Effect.gen(function* () {
662 let captured: Record<string, string | undefined> | undefined;
663 const spawnerLayer = Layer.succeed(
664 ChildProcessSpawner.ChildProcessSpawner,
665 ChildProcessSpawner.make((command) => {
666 captured = (
667 command as {
668 readonly options?: { readonly env?: Record<string, string | undefined> };
669 }
670 ).options?.env;
671 return Effect.succeed(mockProcess(0));
672 }),
673 );
674​
675 yield* runDevRunnerWithInput({ ...devServerInput, t3Home: input.t3Home }).pipe(
676 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
677 Effect.provideService(HostProcess.Platform, "linux"),
678 Effect.provideService(HostProcess.WorkingDirectory, input.cwd),
679 // No ~/.t3 here, so these runs never seed from this machine's install.
680 Effect.provideService(HostProcess.HomeDirectory, input.cwd),
681 Effect.provideService(
682 HostProcess.Environment,
683 input.ambientHome === undefined ? {} : { T3CODE_HOME: input.ambientHome },
684 ),
685 );
686​
687 return captured?.T3CODE_HOME;
688 });
689​
690 it.effect("prefers an explicit --home-dir over the worktree default", () =>
691 Effect.gen(function* () {
692 const path = yield* Path.Path;
693 const root = yield* makeWorktree;
694 const home = yield* spawnedHome({
695 t3Home: "/tmp/explicit-home",
696 cwd: root,
697 ambientHome: "/home/user/.t3",
698 });
699 assert.equal(home, path.resolve("/tmp/explicit-home"));
700 }).pipe(Effect.scoped),
701 );
702​
703 it.effect("treats a blank --home-dir as unset rather than as a selection", () =>
704 Effect.gen(function* () {
705 const path = yield* Path.Path;
706 const root = yield* makeWorktree;
707 const home = yield* spawnedHome({
708 t3Home: " ",
709 cwd: root,
710 ambientHome: "/home/user/.t3",
711 });
712 assert.equal(home, path.join(path.resolve(root), ".t3"));
713 }).pipe(Effect.scoped),
714 );
715​
716 it.effect("prefers the worktree .t3 over an ambient T3CODE_HOME", () =>
717 Effect.gen(function* () {
718 const path = yield* Path.Path;
719 const root = yield* makeWorktree;
720 const home = yield* spawnedHome({
721 t3Home: undefined,
722 cwd: root,
723 ambientHome: "/home/user/.t3",
724 });
725 assert.equal(home, path.join(path.resolve(root), ".t3"));
726 }).pipe(Effect.scoped),
727 );
728​
729 it.effect("seeds a worktree database from ~/.t3 only when it has none", () =>
730 Effect.gen(function* () {
731 const path = yield* Path.Path;
732 const root = yield* makeWorktree;
733 const home = path.join(root, "home");
734 NodeFS.mkdirSync(path.join(home, ".t3", "userdata"), { recursive: true });
735 NodeFS.writeFileSync(path.join(home, ".t3", "userdata", "statev2.sqlite"), "");
736​
737 const spawnedArgs = (seed: boolean | undefined) =>
738 Effect.gen(function* () {
739 const spawned: Array<ReadonlyArray<string>> = [];
740 const spawnerLayer = Layer.succeed(
741 ChildProcessSpawner.ChildProcessSpawner,
742 ChildProcessSpawner.make((command) => {
743 spawned.push(
744 (command as unknown as { readonly args: ReadonlyArray<string> }).args,
745 );
746 return Effect.succeed(mockProcess(0));
747 }),
748 );
749 yield* runDevRunnerWithInput({ ...devServerInput, t3Home: undefined, seed }).pipe(
750 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
751 Effect.provideService(HostProcess.Platform, "linux"),
752 Effect.provideService(HostProcess.WorkingDirectory, root),
753 Effect.provideService(HostProcess.HomeDirectory, home),
754 Effect.provideService(HostProcess.Environment, {}),
755 );
756 return spawned.filter((args) =>
757 args.some((arg) => arg.endsWith("migrate-dev-db.ts")),
758 );
759 });
760​
761 assert.deepStrictEqual(yield* spawnedArgs(false), []);
762 const [seedArgs] = yield* spawnedArgs(undefined);
763 assert.includeMembers([...(seedArgs ?? [])], ["--base-dir", path.join(root, ".t3")]);
764​
765 // Either database means the worktree already has data.
766 for (const name of ["state.sqlite", "statev2.sqlite"]) {
767 const file = path.join(root, ".t3", "userdata", name);
768 NodeFS.mkdirSync(path.dirname(file), { recursive: true });
769 NodeFS.writeFileSync(file, "");
770 assert.deepStrictEqual(yield* spawnedArgs(undefined), []);
771 NodeFS.rmSync(file);
772 }
773 }).pipe(Effect.scoped),
774 );
775​
776 it.effect.each([
777 { sourceBytes: 500 * 1024 * 1024 - 1, shouldSeed: true },
778 { sourceBytes: 500 * 1024 * 1024, shouldSeed: true },
779 { sourceBytes: 500 * 1024 * 1024 + 1, shouldSeed: false },
780 ])(
781 "checks source size before seeding an empty worktree: $sourceBytes bytes",
782 ({ sourceBytes, shouldSeed }) =>
783 Effect.gen(function* () {
784 const fs = yield* FileSystem.FileSystem;
785 const path = yield* Path.Path;
786 const root = yield* makeWorktree;
787 const home = path.join(root, "home");
788 const source = path.join(home, ".t3", "userdata", "statev2.sqlite");
789 NodeFS.mkdirSync(path.dirname(source), { recursive: true });
790 NodeFS.writeFileSync(source, "");
791 const spawned: Array<ReadonlyArray<string>> = [];
792 const warnings: Array<string> = [];
793 const spawnerLayer = Layer.succeed(
794 ChildProcessSpawner.ChildProcessSpawner,
795 ChildProcessSpawner.make((command) => {
796 spawned.push((command as unknown as { readonly args: ReadonlyArray<string> }).args);
797 return Effect.succeed(mockProcess(0));
798 }),
799 );
800​
801 yield* runDevRunnerWithInput({ ...devServerInput, t3Home: undefined }).pipe(
802 Effect.provide([
803 emptyConfigLayer,
804 netServiceLayer,
805 spawnerLayer,
806 Logger.layer([
807 Logger.make((options) => {
808 if (options.logLevel === "Warn") warnings.push(String(options.message));
809 }),
810 ]),
811 ]),
812 Effect.provideService(FileSystem.FileSystem, {
813 ...fs,
814 stat: (file) =>
815 fs
816 .stat(file)
817 .pipe(
818 Effect.map((info) =>
819 file === source ? { ...info, size: ByteSize.bytes(sourceBytes) } : info,
820 ),
821 ),
822 }),
823 Effect.provideService(HostProcess.Platform, "linux"),
824 Effect.provideService(HostProcess.WorkingDirectory, root),
825 Effect.provideService(HostProcess.HomeDirectory, home),
826 Effect.provideService(HostProcess.Environment, {}),
827 );
828​
829 assert.equal(
830 spawned.some((args) => args.some((arg) => arg.endsWith("migrate-dev-db.ts"))),
831 shouldSeed,
832 );
833 assert.deepStrictEqual(spawned.at(-1), [
834 "run",
835 "--filter=t3",
836 "dev",
837 ...devServerInput.runArgs,
838 ]);
839 if (shouldSeed) {
840 assert.deepStrictEqual(warnings, []);
841 } else {
842 assert.lengthOf(warnings, 1);
843 assert.include(warnings[0]!, source);
844 assert.include(warnings[0]!, "skipping automatic database seeding");
845 assert.include(warnings[0]!, "vp run migrate-dev-db");
846 }
847 }).pipe(Effect.scoped),
848 );
849​
850 it.effect("falls back to an ambient T3CODE_HOME outside a worktree", () =>
851 Effect.gen(function* () {
852 const path = yield* Path.Path;
853 const home = yield* spawnedHome({
854 t3Home: undefined,
855 cwd: NodeOS.tmpdir(),
856 ambientHome: "/home/user/.t3",
857 });
858 assert.equal(home, path.resolve("/home/user/.t3"));
859 }),
860 );
861 });
862 });
863});