Skip to content
Source
Sign inSign up

dev-runner.test.ts

872 lines32.0 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 // Node resolves localhost to ::1 first, so a 127.0.0.1 target can miss the backend.
578 it.effect("--built --host localhost keeps the backend's localhost name", () =>
579 Effect.gen(function* () {
580 const { env, tailscaleCalls } = yield* shareSpawned({ built: true, host: "localhost" });
581 const serve = tailscaleCalls.find((args) => args.includes("--bg"));
582 assert.equal(serve?.at(-1), `http://localhost:${env?.T3CODE_PORT}`);
583 }),
584 );
585​
586 it.effect("without --built keeps Vite behind the tailnet URL", () =>
587 Effect.gen(function* () {
588 const { env, vpArgs, tailscaleCalls } = yield* shareSpawned({});
589 assert.include(vpArgs, "dev");
590 assert.notInclude(vpArgs, "dev:built");
591 assert.equal(env?.T3CODE_BUNDLED_DEV, "1");
592 const serve = tailscaleCalls.find((args) => args.includes("--bg"));
593 assert.equal(serve?.at(-1), `http://localhost:${env?.PORT}`);
594 }),
595 );
596 });
597​
598 it.effect.each([
599 { mode: "dev", share: false },
600 { mode: "dev:web", share: true },
601 { mode: "dev:desktop", share: true },
602 ] as const)("rejects --built for $mode with share=$share", ({ mode, share }) => {
603 const spawnerLayer = Layer.succeed(
604 ChildProcessSpawner.ChildProcessSpawner,
605 ChildProcessSpawner.make(() => Effect.succeed(mockProcess(0))),
606 );
607​
608 return Effect.gen(function* () {
609 const error = yield* runDevRunnerWithInput({
610 ...devServerInput,
611 mode,
612 port: undefined,
613 share,
614 built: true,
615 }).pipe(
616 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
617 Effect.provideService(HostProcess.Platform, "linux"),
618 Effect.flip,
619 );
620​
621 assert.equal(error._tag, "DevRunnerBuiltNeedsShareError");
622 });
623 });
624​
625 it.effect("reports non-zero exits without manufacturing a cause", () => {
626 const spawnerLayer = Layer.succeed(
627 ChildProcessSpawner.ChildProcessSpawner,
628 ChildProcessSpawner.make(() => Effect.succeed(mockProcess(17))),
629 );
630​
631 return Effect.gen(function* () {
632 const error = yield* runDevRunnerWithInput(devServerInput).pipe(
633 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
634 Effect.provideService(HostProcess.Platform, "linux"),
635 Effect.flip,
636 );
637​
638 if (error._tag !== "DevRunnerProcessExitError") {
639 assert.fail(`Unexpected error: ${error._tag}`);
640 }
641 assert.equal(error.mode, "dev:server");
642 assert.equal(error.executable, "vp");
643 assert.equal(error.argumentCount, 5);
644 assert.equal(error.shell, false);
645 assert.equal(error.exitCode, 17);
646 assert.ok(!("cause" in error));
647 assert.notProperty(error, "args");
648 assert.notInclude(error.message, "secret-token-value");
649 });
650 });
651​
652 describe("t3 home precedence", () => {
653 const makeWorktree = Effect.acquireRelease(
654 Effect.sync(() => {
655 const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-devrunner-"));
656 NodeFS.writeFileSync(
657 NodePath.join(root, ".git"),
658 "gitdir: /elsewhere/.git/worktrees/x\n",
659 );
660 return root;
661 }),
662 (root) => Effect.sync(() => NodeFS.rmSync(root, { recursive: true, force: true })),
663 );
664​
665 const spawnedHome = (input: {
666 readonly t3Home: string | undefined;
667 readonly cwd: string;
668 readonly ambientHome: string | undefined;
669 }) =>
670 Effect.gen(function* () {
671 let captured: Record<string, string | undefined> | undefined;
672 const spawnerLayer = Layer.succeed(
673 ChildProcessSpawner.ChildProcessSpawner,
674 ChildProcessSpawner.make((command) => {
675 captured = (
676 command as {
677 readonly options?: { readonly env?: Record<string, string | undefined> };
678 }
679 ).options?.env;
680 return Effect.succeed(mockProcess(0));
681 }),
682 );
683​
684 yield* runDevRunnerWithInput({ ...devServerInput, t3Home: input.t3Home }).pipe(
685 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
686 Effect.provideService(HostProcess.Platform, "linux"),
687 Effect.provideService(HostProcess.WorkingDirectory, input.cwd),
688 // No ~/.t3 here, so these runs never seed from this machine's install.
689 Effect.provideService(HostProcess.HomeDirectory, input.cwd),
690 Effect.provideService(
691 HostProcess.Environment,
692 input.ambientHome === undefined ? {} : { T3CODE_HOME: input.ambientHome },
693 ),
694 );
695​
696 return captured?.T3CODE_HOME;
697 });
698​
699 it.effect("prefers an explicit --home-dir over the worktree default", () =>
700 Effect.gen(function* () {
701 const path = yield* Path.Path;
702 const root = yield* makeWorktree;
703 const home = yield* spawnedHome({
704 t3Home: "/tmp/explicit-home",
705 cwd: root,
706 ambientHome: "/home/user/.t3",
707 });
708 assert.equal(home, path.resolve("/tmp/explicit-home"));
709 }).pipe(Effect.scoped),
710 );
711​
712 it.effect("treats a blank --home-dir as unset rather than as a selection", () =>
713 Effect.gen(function* () {
714 const path = yield* Path.Path;
715 const root = yield* makeWorktree;
716 const home = yield* spawnedHome({
717 t3Home: " ",
718 cwd: root,
719 ambientHome: "/home/user/.t3",
720 });
721 assert.equal(home, path.join(path.resolve(root), ".t3"));
722 }).pipe(Effect.scoped),
723 );
724​
725 it.effect("prefers the worktree .t3 over an ambient T3CODE_HOME", () =>
726 Effect.gen(function* () {
727 const path = yield* Path.Path;
728 const root = yield* makeWorktree;
729 const home = yield* spawnedHome({
730 t3Home: undefined,
731 cwd: root,
732 ambientHome: "/home/user/.t3",
733 });
734 assert.equal(home, path.join(path.resolve(root), ".t3"));
735 }).pipe(Effect.scoped),
736 );
737​
738 it.effect("seeds a worktree database from ~/.t3 only when it has none", () =>
739 Effect.gen(function* () {
740 const path = yield* Path.Path;
741 const root = yield* makeWorktree;
742 const home = path.join(root, "home");
743 NodeFS.mkdirSync(path.join(home, ".t3", "userdata"), { recursive: true });
744 NodeFS.writeFileSync(path.join(home, ".t3", "userdata", "statev2.sqlite"), "");
745​
746 const spawnedArgs = (seed: boolean | undefined) =>
747 Effect.gen(function* () {
748 const spawned: Array<ReadonlyArray<string>> = [];
749 const spawnerLayer = Layer.succeed(
750 ChildProcessSpawner.ChildProcessSpawner,
751 ChildProcessSpawner.make((command) => {
752 spawned.push(
753 (command as unknown as { readonly args: ReadonlyArray<string> }).args,
754 );
755 return Effect.succeed(mockProcess(0));
756 }),
757 );
758 yield* runDevRunnerWithInput({ ...devServerInput, t3Home: undefined, seed }).pipe(
759 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
760 Effect.provideService(HostProcess.Platform, "linux"),
761 Effect.provideService(HostProcess.WorkingDirectory, root),
762 Effect.provideService(HostProcess.HomeDirectory, home),
763 Effect.provideService(HostProcess.Environment, {}),
764 );
765 return spawned.filter((args) =>
766 args.some((arg) => arg.endsWith("migrate-dev-db.ts")),
767 );
768 });
769​
770 assert.deepStrictEqual(yield* spawnedArgs(false), []);
771 const [seedArgs] = yield* spawnedArgs(undefined);
772 assert.includeMembers([...(seedArgs ?? [])], ["--base-dir", path.join(root, ".t3")]);
773​
774 // Either database means the worktree already has data.
775 for (const name of ["state.sqlite", "statev2.sqlite"]) {
776 const file = path.join(root, ".t3", "userdata", name);
777 NodeFS.mkdirSync(path.dirname(file), { recursive: true });
778 NodeFS.writeFileSync(file, "");
779 assert.deepStrictEqual(yield* spawnedArgs(undefined), []);
780 NodeFS.rmSync(file);
781 }
782 }).pipe(Effect.scoped),
783 );
784​
785 it.effect.each([
786 { sourceBytes: 500 * 1024 * 1024 - 1, shouldSeed: true },
787 { sourceBytes: 500 * 1024 * 1024, shouldSeed: true },
788 { sourceBytes: 500 * 1024 * 1024 + 1, shouldSeed: false },
789 ])(
790 "checks source size before seeding an empty worktree: $sourceBytes bytes",
791 ({ sourceBytes, shouldSeed }) =>
792 Effect.gen(function* () {
793 const fs = yield* FileSystem.FileSystem;
794 const path = yield* Path.Path;
795 const root = yield* makeWorktree;
796 const home = path.join(root, "home");
797 const source = path.join(home, ".t3", "userdata", "statev2.sqlite");
798 NodeFS.mkdirSync(path.dirname(source), { recursive: true });
799 NodeFS.writeFileSync(source, "");
800 const spawned: Array<ReadonlyArray<string>> = [];
801 const warnings: Array<string> = [];
802 const spawnerLayer = Layer.succeed(
803 ChildProcessSpawner.ChildProcessSpawner,
804 ChildProcessSpawner.make((command) => {
805 spawned.push((command as unknown as { readonly args: ReadonlyArray<string> }).args);
806 return Effect.succeed(mockProcess(0));
807 }),
808 );
809​
810 yield* runDevRunnerWithInput({ ...devServerInput, t3Home: undefined }).pipe(
811 Effect.provide([
812 emptyConfigLayer,
813 netServiceLayer,
814 spawnerLayer,
815 Logger.layer([
816 Logger.make((options) => {
817 if (options.logLevel === "Warn") warnings.push(String(options.message));
818 }),
819 ]),
820 ]),
821 Effect.provideService(FileSystem.FileSystem, {
822 ...fs,
823 stat: (file) =>
824 fs
825 .stat(file)
826 .pipe(
827 Effect.map((info) =>
828 file === source ? { ...info, size: ByteSize.bytes(sourceBytes) } : info,
829 ),
830 ),
831 }),
832 Effect.provideService(HostProcess.Platform, "linux"),
833 Effect.provideService(HostProcess.WorkingDirectory, root),
834 Effect.provideService(HostProcess.HomeDirectory, home),
835 Effect.provideService(HostProcess.Environment, {}),
836 );
837​
838 assert.equal(
839 spawned.some((args) => args.some((arg) => arg.endsWith("migrate-dev-db.ts"))),
840 shouldSeed,
841 );
842 assert.deepStrictEqual(spawned.at(-1), [
843 "run",
844 "--filter=t3",
845 "dev",
846 ...devServerInput.runArgs,
847 ]);
848 if (shouldSeed) {
849 assert.deepStrictEqual(warnings, []);
850 } else {
851 assert.lengthOf(warnings, 1);
852 assert.include(warnings[0]!, source);
853 assert.include(warnings[0]!, "skipping automatic database seeding");
854 assert.include(warnings[0]!, "vp run migrate-dev-db");
855 }
856 }).pipe(Effect.scoped),
857 );
858​
859 it.effect("falls back to an ambient T3CODE_HOME outside a worktree", () =>
860 Effect.gen(function* () {
861 const path = yield* Path.Path;
862 const home = yield* spawnedHome({
863 t3Home: undefined,
864 cwd: NodeOS.tmpdir(),
865 ambientHome: "/home/user/.t3",
866 });
867 assert.equal(home, path.resolve("/home/user/.t3"));
868 }),
869 );
870 });
871 });
872});