1// @effect-diagnostics nodeBuiltinImport:off - Creates isolated Unix socket fixtures outside the service.
2import { afterEach, beforeEach, describe, expect, vi } from "vite-plus/test";
3import { it } from "@effect/vitest";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import {
6 ProjectId,
7 ThreadId,
8 DevServerError,
9 type OrchestrationV2ThreadShell,
10} from "@t3tools/contracts";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import * as Effect from "effect/Effect";
13import * as DateTime from "effect/DateTime";
14import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import { HttpClient, HttpClientResponse } from "effect/http";
18import * as NodeFSP from "node:fs/promises";
19import * as NodeNet from "node:net";
20import * as NodeOS from "node:os";
21import * as NodePath from "node:path";
22
23import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
24import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
25import * as ProcessRunner from "../processRunner.ts";
26import * as DevServer from "./DevServer.ts";
27
28const target = { _tag: "Thread", threadId: ThreadId.make("thread") } as const;
29let home: string;
30let root: string;
31let projectRoot: string;
32let server: NodeNet.Server | undefined;
33let state: {
34 root: string;
35 dataDir: string;
36 environment: string;
37 stopping: boolean;
38 services: Record<string, { status: string; url: string | null }>;
39};
40let malformed = false;
41let httpStatus = 200;
42const launches = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>();
43const requestedUrls: string[] = [];
44const commands: ReadonlyArray<string>[] = [];
45let statusObserved: (() => void) | undefined;
46let shutdownCode = 0;
47let snapshotReads = 0;
48let recordsObserved: ((threadId: ThreadId) => void) | undefined;
49type ThreadFields = Pick<
50 OrchestrationV2ThreadShell,
51 | "id"
52 | "projectId"
53 | "worktreePath"
54 | "archivedAt"
55 | "settledOverride"
56 | "settledAt"
57 | "activeRunId"
58 | "activityRunStatus"
59 | "status"
60 | "pendingRuntimeRequest"
61 | "pendingBackgroundTasks"
62 | "deletedAt"
63 | "lineage"
64 | "creationSource"
65>;
66type Thread = { -readonly [K in keyof ThreadFields]: ThreadFields[K] };
67let threadRefs: Thread[];
68let projectRefs: { id: ProjectId; workspaceRoot: string }[];
69let runStatuses: Record<string, string[]>;
70let pendingRequests: Record<string, boolean>;
71function thread(overrides: Partial<Thread> = {}): Thread {
72 return {
73 id: target.threadId,
74 projectId: ProjectId.make("project"),
75 worktreePath: root,
76 archivedAt: null,
77 deletedAt: null,
78 creationSource: "web",
79 lineage: { rootThreadId: target.threadId, parentThreadId: null, relationshipToParent: null },
80 settledOverride: "settled",
81 settledAt: null,
82 activeRunId: null,
83 activityRunStatus: null,
84 status: "completed",
85 pendingRuntimeRequest: null,
86 pendingBackgroundTasks: [],
87 ...overrides,
88 };
89}
90
91beforeEach(async () => {
92 home = await NodeFSP.realpath(await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ds-")));
93 root = NodePath.join(home, "wt");
94 projectRoot = NodePath.join(home, "project");
95 await NodeFSP.mkdir(NodePath.join(root, ".dev-tui"), { recursive: true });
96 await NodeFSP.mkdir(projectRoot);
97 state = {
98 root,
99 dataDir: NodePath.join(root, ".dev-tui"),
100 environment: "local",
101 stopping: false,
102 services: {},
103 };
104 malformed = false;
105 httpStatus = 200;
106 requestedUrls.length = 0;
107 launches.mockReset();
108 commands.length = 0;
109 statusObserved = undefined;
110 shutdownCode = 0;
111 snapshotReads = 0;
112 recordsObserved = undefined;
113 threadRefs = [thread()];
114 projectRefs = [{ id: ProjectId.make("project"), workspaceRoot: projectRoot }];
115 runStatuses = {};
116 pendingRequests = {};
117});
118
119afterEach(async () => {
120 if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
121 server = undefined;
122 await NodeFSP.rm(home, { recursive: true, force: true });
123});
124
125async function socket(socketPath = NodePath.join(root, ".dev-tui", "ipc.sock")) {
126 server = NodeNet.createServer((connection) => {
127 connection.once("data", (command) => {
128 const args = JSON.parse(command.toString()) as string[];
129 commands.push(args);
130 if (args[0] === "shutdown") {
131 connection.end(
132 JSON.stringify({ out: "ok\n" }) + "\n" + JSON.stringify({ code: shutdownCode }) + "\n",
133 );
134 return;
135 }
136 expect(args).toEqual(["status", "--json"]);
137 statusObserved?.();
138 if (malformed) {
139 connection.end("garbage\n");
140 return;
141 }
142 const output = JSON.stringify(state);
143 // Both frames and the JSON payload may cross socket chunk boundaries.
144 connection.write(JSON.stringify({ out: output.slice(0, 10) }) + "\n");
145 const last =
146 JSON.stringify({ out: output.slice(10) }) + "\n" + JSON.stringify({ code: 0 }) + "\n";
147 connection.write(last.slice(0, 5));
148 connection.end(last.slice(5));
149 });
150 });
151 await new Promise<void>((resolve, reject) => {
152 server!.once("error", reject);
153 server!.listen(socketPath, resolve);
154 });
155}
156
157async function workProject() {
158 await NodeFSP.mkdir(NodePath.join(home, "dev", "dev-tui"), { recursive: true });
159 await Promise.all([
160 NodeFSP.writeFile(NodePath.join(root, "dev.sh"), ""),
161 NodeFSP.writeFile(NodePath.join(root, "dev.config.ts"), ""),
162 NodeFSP.writeFile(NodePath.join(home, "dev", "dev-tui", "dev.sh"), ""),
163 ]);
164}
165
166const layer = () =>
167 DevServer.layer.pipe(
168 Layer.provide(
169 Layer.mergeAll(
170 NodeServices.layer,
171 Layer.succeed(HostProcess.HomeDirectory, home),
172 Layer.succeed(HostProcess.Environment, { PATH: "/test/bin" }),
173 Layer.succeed(ProjectStore.ProjectStoreV2, {
174 get: (id: ProjectId) =>
175 Effect.sync(() => Option.fromNullishOr(projectRefs.find((p) => p.id === id))),
176 getShell: (id: ProjectId) =>
177 Effect.sync(() => Option.fromNullishOr(projectRefs.find((p) => p.id === id))),
178 listShells: () => Effect.sync(() => projectRefs.slice()),
179 } as unknown as ProjectStore.ProjectStoreV2["Service"]),
180 Layer.succeed(ProjectionStore.ProjectionStoreV2, {
181 getThread: (id: ThreadId) => Effect.sync(() => threadRefs.find((t) => t.id === id)),
182 getShellSnapshot: () =>
183 Effect.sync(() => {
184 snapshotReads++;
185 return {
186 threads: threadRefs.filter((t) => t.archivedAt === null && t.deletedAt === null),
187 archivedThreads: threadRefs.filter(
188 (t) => t.archivedAt !== null && t.deletedAt === null,
189 ),
190 };
191 }),
192 getThreadRecords: (id: ThreadId) =>
193 Effect.suspend(() => {
194 recordsObserved?.(id);
195 const found = threadRefs.find((t) => t.id === id);
196 return found === undefined
197 ? Effect.fail(
198 new ProjectionStore.ProjectionStoreThreadNotFoundError({ threadId: id }),
199 )
200 : Effect.succeed({
201 thread: found,
202 runs: (runStatuses[id] ?? []).map((status) => ({ status })),
203 runtimeRequests: pendingRequests[id] ? [{ status: "pending" }] : [],
204 });
205 }),
206 } as unknown as ProjectionStore.ProjectionStoreV2["Service"]),
207 Layer.succeed(ProcessRunner.ProcessRunner, { run: launches }),
208 Layer.succeed(
209 HttpClient.HttpClient,
210 HttpClient.make((request) => {
211 requestedUrls.push(request.url);
212 return Effect.succeed(
213 HttpClientResponse.fromWeb(request, new Response(null, { status: httpStatus })),
214 );
215 }),
216 ),
217 ),
218 ),
219 );
220const run = <A>(effect: Effect.Effect<A, DevServerError, DevServer.DevServer>) =>
221 effect.pipe(Effect.provide(layer()));
222const launched = {
223 stdout: "",
224 stderr: "",
225 code: ChildProcessSpawner.ExitCode(0),
226 timedOut: false,
227 stdoutTruncated: false,
228 stderrTruncated: false,
229 stdoutInvalidUtf8: false,
230 stderrInvalidUtf8: false,
231} as const;
232
233describe("DevServer", () => {
234 it.effect("reads this thread's worktree and requires a healthy HTTP frontend", () =>
235 Effect.gen(function* () {
236 yield* Effect.promise(() => socket());
237 state.services = {
238 client: { status: "ready", url: "http://localhost:3210" },
239 api: { status: "crashed", url: null },
240 };
241 const status = yield* run(
242 Effect.flatMap(DevServer.DevServer, (service) => service.status(target)),
243 );
244 expect(status).toMatchObject({
245 workspaceRoot: root,
246 canStart: false,
247 frontendUrl: "http://localhost:3210/",
248 });
249 expect(status.services).toHaveLength(2);
250 expect(requestedUrls).toEqual(["http://localhost:3210/"]);
251 httpStatus = 503;
252 const unavailable = yield* run(
253 Effect.flatMap(DevServer.DevServer, (service) => service.status(target)),
254 );
255 expect(unavailable.frontendUrl).toBeNull();
256 }),
257 );
258
259 it.effect("uses the selected pre-send workspace rather than the project's root", () =>
260 Effect.gen(function* () {
261 yield* Effect.promise(() => socket());
262 state.services.client = { status: "ready", url: "http://localhost:3210" };
263 const draft = {
264 _tag: "Project",
265 projectId: ProjectId.make("project"),
266 workspacePath: root,
267 } as const;
268 const status = yield* run(
269 Effect.flatMap(DevServer.DevServer, (service) => service.status(draft)),
270 );
271 expect(status.workspaceRoot).toBe(root);
272 expect(status.frontendUrl).toBe("http://localhost:3210/");
273 }),
274 );
275
276 it.effect("recognizes stopped work project without launching during a status read", () =>
277 Effect.gen(function* () {
278 yield* Effect.promise(workProject);
279 const status = yield* run(
280 Effect.flatMap(DevServer.DevServer, (service) => service.status(target)),
281 );
282 expect(status).toMatchObject({ canStart: true, services: [], frontendUrl: null });
283 expect(launches).not.toHaveBeenCalled();
284 }),
285 );
286
287 it.effect("never starts arbitrary workspaces or executes status text", () =>
288 Effect.gen(function* () {
289 yield* Effect.promise(() => socket());
290 state.services.client = { status: "starting", url: "bash /tmp/execute-me" };
291 const error = yield* run(
292 Effect.flatMap(DevServer.DevServer, (service) => service.start(target)),
293 ).pipe(Effect.flip);
294 expect(error).toMatchObject({ reason: "unsupported" });
295 expect(launches).not.toHaveBeenCalled();
296 expect(requestedUrls).toEqual([]);
297 }),
298 );
299
300 it.effect.each(["wrong-root", "remote-url", "malformed", "stopping"])(
301 "rejects or withholds a frontend for %s",
302 (scenario) =>
303 Effect.gen(function* () {
304 yield* Effect.promise(() => socket());
305 state.services.client = { status: "ready", url: "http://localhost:3210" };
306 if (scenario === "wrong-root") state.root = projectRoot;
307 if (scenario === "remote-url") state.services.client.url = "https://example.com/";
308 if (scenario === "malformed") malformed = true;
309 if (scenario === "stopping") {
310 state.stopping = true;
311 const status = yield* run(
312 Effect.flatMap(DevServer.DevServer, (service) => service.status(target)),
313 );
314 expect(status.frontendUrl).toBeNull();
315 } else {
316 const error = yield* run(
317 Effect.flatMap(DevServer.DevServer, (service) => service.status(target)),
318 ).pipe(Effect.flip);
319 expect(error).toMatchObject({ reason: "status" });
320 }
321 expect(requestedUrls).toEqual([]);
322 }),
323 );
324
325 it.effect("deduplicates concurrent launch requests and uses the current detached launcher", () =>
326 Effect.gen(function* () {
327 yield* Effect.promise(workProject);
328 launches.mockImplementation(() =>
329 Effect.promise(async () => {
330 await socket();
331 state.services.client = { status: "ready", url: "http://localhost:3210" };
332 return launched;
333 }),
334 );
335 const results = yield* run(
336 Effect.flatMap(DevServer.DevServer, (service) =>
337 Effect.all([service.start(target), service.start(target)], { concurrency: "unbounded" }),
338 ),
339 );
340 expect(results.map((status) => status.frontendUrl)).toEqual([
341 "http://localhost:3210/",
342 "http://localhost:3210/",
343 ]);
344 expect(launches).toHaveBeenCalledTimes(1);
345 expect(launches).toHaveBeenCalledWith(
346 expect.objectContaining({
347 command: "bash",
348 args: [NodePath.join(root, "dev.sh"), "--headless"],
349 cwd: root,
350 env: { PATH: "/test/bin", DEV_TUI_DIR: NodePath.join(home, "dev", "dev-tui") },
351 }),
352 );
353 }),
354 );
355
356 it.effect("returns a launch error when the detached launcher reports startup failure", () =>
357 Effect.gen(function* () {
358 yield* Effect.promise(workProject);
359 launches.mockImplementation(() =>
360 Effect.succeed({ ...launched, code: ChildProcessSpawner.ExitCode(1) }),
361 );
362 const error = yield* run(
363 Effect.flatMap(DevServer.DevServer, (service) => service.start(target)),
364 ).pipe(Effect.flip);
365 expect(error).toMatchObject({ reason: "launch" });
366 expect(requestedUrls).toEqual([]);
367 }),
368 );
369
370 it.effect("does not open when launcher succeeds before frontend HTTP readiness", () =>
371 Effect.gen(function* () {
372 yield* Effect.promise(workProject);
373 yield* Effect.promise(() => socket());
374 launches.mockImplementation(() =>
375 Effect.sync(() => {
376 state.services.client = { status: "ready", url: "http://localhost:3210" };
377 return launched;
378 }),
379 );
380 httpStatus = 503;
381 const error = yield* run(
382 Effect.flatMap(DevServer.DevServer, (service) => service.start(target)),
383 ).pipe(Effect.flip);
384 expect(error).toMatchObject({ reason: "not-ready" });
385 }),
386 );
387
388 describe("stopUnused", () => {
389 const stop = () =>
390 run(Effect.flatMap(DevServer.DevServer, (service) => service.stopUnused(target.threadId)));
391
392 it.effect(
393 "requests shutdown only through the verified default socket, without launching or deleting a workspace",
394 () =>
395 Effect.gen(function* () {
396 yield* Effect.promise(() => socket());
397 yield* stop();
398 expect(commands).toEqual([["status", "--json"], ["shutdown"]]);
399 expect(launches).not.toHaveBeenCalled();
400 expect(requestedUrls).toEqual([]);
401 expect(yield* Effect.promise(() => NodeFSP.stat(root))).toBeDefined();
402 // Acknowledgement is not an exit: a still-running fixture can receive a retry.
403 yield* stop();
404 expect(commands.filter((args) => args[0] === "shutdown")).toHaveLength(2);
405 }),
406 );
407
408 it.effect("stops a project-root runtime when the thread has no separate worktree", () =>
409 Effect.gen(function* () {
410 projectRefs[0]!.workspaceRoot = root;
411 threadRefs[0]!.worktreePath = null;
412 yield* Effect.promise(() => socket());
413 yield* stop();
414 expect(commands).toEqual([["status", "--json"], ["shutdown"]]);
415 }),
416 );
417
418 it.effect("does not fall back to the project runtime when a managed worktree is gone", () =>
419 Effect.gen(function* () {
420 projectRefs[0]!.workspaceRoot = root;
421 threadRefs[0]!.worktreePath = NodePath.join(home, "missing-worktree");
422 yield* Effect.promise(() => socket());
423 yield* stop();
424 expect(commands).toEqual([]);
425 }),
426 );
427
428 it.effect("missing sockets and missing threads are idempotent", () =>
429 Effect.gen(function* () {
430 yield* stop();
431 expect(snapshotReads).toBe(0);
432 threadRefs.length = 0;
433 yield* stop();
434 expect(commands).toEqual([]);
435 }),
436 );
437
438 it.effect.each(["active-target", "active-peer", "other-project", "descendant", "symlink"])(
439 "protects %s",
440 (scenario) =>
441 Effect.gen(function* () {
442 yield* Effect.promise(() => socket());
443 if (scenario === "active-target") {
444 threadRefs[0]!.settledOverride = "active";
445 } else {
446 const peer = thread({ id: ThreadId.make("peer"), settledOverride: "active" });
447 if (scenario === "other-project") {
448 peer.projectId = ProjectId.make("another-project");
449 peer.worktreePath = null;
450 projectRefs.push({ id: peer.projectId, workspaceRoot: root });
451 }
452 if (scenario === "descendant") {
453 peer.worktreePath = NodePath.join(root, "nested");
454 if (scenario === "descendant")
455 yield* Effect.promise(() => NodeFSP.mkdir(peer.worktreePath!));
456 }
457 if (scenario === "symlink") {
458 peer.worktreePath = NodePath.join(home, "alias");
459 yield* Effect.promise(() => NodeFSP.symlink(root, peer.worktreePath!));
460 }
461 threadRefs.push(peer);
462 }
463 yield* stop();
464 expect(commands).toEqual([]);
465 }),
466 );
467
468 it.effect("a missing peer workspace cannot host or use this runtime", () =>
469 Effect.gen(function* () {
470 threadRefs.push(
471 thread({
472 id: ThreadId.make("missing-peer"),
473 worktreePath: NodePath.join(root, "gone"),
474 settledOverride: "active",
475 }),
476 );
477 yield* Effect.promise(() => socket());
478 yield* stop();
479 expect(commands).toEqual([["status", "--json"], ["shutdown"]]);
480 }),
481 );
482
483 it.effect("ignores E2E and custom data directories when the default socket is absent", () =>
484 Effect.gen(function* () {
485 const directory = NodePath.join(root, ".dev-tui-e2e");
486 yield* Effect.promise(() => NodeFSP.mkdir(directory));
487 yield* Effect.promise(() => socket(NodePath.join(directory, "ipc.sock")));
488 yield* stop();
489 expect(commands).toEqual([]);
490 }),
491 );
492
493 it.effect("an unrelated active thread does not protect this workspace", () =>
494 Effect.gen(function* () {
495 threadRefs.push(
496 thread({
497 id: ThreadId.make("elsewhere"),
498 worktreePath: projectRoot,
499 settledOverride: "active",
500 }),
501 );
502 yield* Effect.promise(() => socket());
503 yield* stop();
504 expect(commands).toEqual([["status", "--json"], ["shutdown"]]);
505 }),
506 );
507
508 it.effect.each([
509 "queued",
510 "preparing",
511 "starting",
512 "running",
513 "waiting",
514 "request",
515 "background",
516 ])("protects an archived or settled peer with %s work", (scenario) =>
517 Effect.gen(function* () {
518 const peer = thread({
519 id: ThreadId.make("peer"),
520 archivedAt: DateTime.makeUnsafe("2026-10-14T00:00:00Z"),
521 });
522 threadRefs.push(peer);
523 if (scenario === "request") pendingRequests[peer.id] = true;
524 else if (scenario === "background")
525 peer.pendingBackgroundTasks = [{ kind: "monitor", taskId: "watch" }];
526 else runStatuses[peer.id] = [scenario, "completed"];
527 yield* Effect.promise(() => socket());
528 yield* stop();
529 expect(commands).toEqual([]);
530 }),
531 );
532
533 it.effect("stops a deleted thread's runtime without requiring a visible shell", () =>
534 Effect.gen(function* () {
535 threadRefs[0]!.deletedAt = DateTime.makeUnsafe("2026-10-14T00:00:00Z");
536 threadRefs[0]!.settledOverride = null;
537 yield* Effect.promise(() => socket());
538 yield* stop();
539 expect(commands).toEqual([["status", "--json"], ["shutdown"]]);
540 }),
541 );
542
543 it.effect.each([
544 "completed",
545 "running",
546 "missing-owner",
547 "active-owner",
548 "mcp-child",
549 "deleted-owner",
550 "parent-owner",
551 ])("handles a %s native child", (scenario) =>
552 Effect.gen(function* () {
553 const child = thread({
554 id: ThreadId.make("child"),
555 settledOverride: null,
556 creationSource: scenario === "mcp-child" ? "mcp" : "provider",
557 lineage: {
558 rootThreadId: scenario === "missing-owner" ? ThreadId.make("missing") : target.threadId,
559 parentThreadId: target.threadId,
560 relationshipToParent: "subagent",
561 },
562 });
563 if (scenario === "parent-owner")
564 child.lineage = { ...child.lineage, rootThreadId: child.id };
565 threadRefs.push(child);
566 if (scenario === "running") runStatuses[child.id] = ["running"];
567 if (scenario === "active-owner") threadRefs[0]!.settledOverride = "active";
568 if (scenario === "deleted-owner") {
569 threadRefs[0]!.deletedAt = DateTime.makeUnsafe("2026-10-14T00:00:00Z");
570 threadRefs[0]!.settledOverride = null;
571 }
572 yield* Effect.promise(() => socket());
573 yield* stop();
574 expect(commands.filter((args) => args[0] === "shutdown")).toHaveLength(
575 scenario === "completed" || scenario === "deleted-owner" || scenario === "parent-owner"
576 ? 1
577 : 0,
578 );
579 }),
580 );
581
582 it.effect("leftover commands do not hold a settled workspace open", () =>
583 Effect.gen(function* () {
584 threadRefs[0]!.pendingBackgroundTasks = [{ kind: "command", taskId: "dev-tui" }];
585 yield* Effect.promise(() => socket());
586 yield* stop();
587 expect(commands).toEqual([["status", "--json"], ["shutdown"]]);
588 }),
589 );
590
591 it.effect("a revival during the status probe prevents shutdown", () =>
592 Effect.gen(function* () {
593 yield* Effect.promise(() => socket());
594 statusObserved = () => {
595 threadRefs[0]!.settledOverride = "active";
596 };
597 yield* stop();
598 expect(commands).toEqual([["status", "--json"]]);
599 }),
600 );
601
602 it.effect.each(["active-peer", "queued-child", "moved-target"])(
603 "rechecks %s added during the status probe",
604 (scenario) =>
605 Effect.gen(function* () {
606 yield* Effect.promise(() => socket());
607 statusObserved = () => {
608 if (scenario === "moved-target") threadRefs[0]!.worktreePath = projectRoot;
609 else {
610 const peer = thread({
611 id: ThreadId.make("new-peer"),
612 settledOverride: scenario === "active-peer" ? "active" : "settled",
613 });
614 threadRefs.push(peer);
615 if (scenario === "queued-child") runStatuses[peer.id] = ["queued", "completed"];
616 }
617 };
618 yield* stop();
619 expect(commands).toEqual([["status", "--json"]]);
620 }),
621 );
622
623 it.effect.each(["peer", "native-owner"])(
624 "protects a %s manually revived after the final snapshot",
625 (scenario) =>
626 Effect.gen(function* () {
627 const peerId = ThreadId.make("peer");
628 threadRefs.push(thread({ id: peerId }));
629 if (scenario === "native-owner")
630 threadRefs.splice(
631 1,
632 0,
633 thread({
634 id: ThreadId.make("native-child"),
635 settledOverride: null,
636 creationSource: "provider",
637 lineage: {
638 rootThreadId: peerId,
639 parentThreadId: peerId,
640 relationshipToParent: "subagent",
641 },
642 }),
643 );
644 yield* Effect.promise(() => socket());
645 statusObserved = () => {
646 recordsObserved = (id) => {
647 if (snapshotReads < 2 || id !== peerId) return;
648 threadRefs = threadRefs.map((thread) =>
649 thread.id === peerId ? { ...thread, settledOverride: "active" } : thread,
650 );
651 };
652 };
653 yield* stop();
654 expect(commands).toEqual([["status", "--json"]]);
655 }),
656 );
657
658 it.effect.each(["wrong-root", "custom-data-dir", "symlink-socket", "symlink-data-dir"])(
659 "rejects %s without shutdown",
660 (scenario) =>
661 Effect.gen(function* () {
662 if (scenario === "wrong-root") state.root = projectRoot;
663 if (scenario === "custom-data-dir") state.dataDir = projectRoot;
664 if (scenario === "symlink-socket") {
665 const alternate = NodePath.join(home, "e2e.sock");
666 yield* Effect.promise(() => socket(alternate));
667 yield* Effect.promise(() =>
668 NodeFSP.symlink(alternate, NodePath.join(root, ".dev-tui", "ipc.sock")),
669 );
670 } else if (scenario === "symlink-data-dir") {
671 yield* Effect.promise(() =>
672 NodeFSP.rm(NodePath.join(root, ".dev-tui"), { recursive: true }),
673 );
674 yield* Effect.promise(() =>
675 NodeFSP.symlink(projectRoot, NodePath.join(root, ".dev-tui")),
676 );
677 state.dataDir = projectRoot;
678 yield* Effect.promise(() => socket());
679 } else yield* Effect.promise(() => socket());
680 expect(yield* stop().pipe(Effect.flip)).toMatchObject({ reason: "status" });
681 expect(commands).toEqual([["status", "--json"]]);
682 }),
683 );
684
685 it.effect("a failed shutdown remains retryable", () =>
686 Effect.gen(function* () {
687 yield* Effect.promise(() => socket());
688 shutdownCode = 1;
689 expect(yield* stop().pipe(Effect.flip)).toMatchObject({ reason: "status" });
690 shutdownCode = 0;
691 yield* stop();
692 expect(commands.filter((args) => args[0] === "shutdown")).toHaveLength(2);
693 }),
694 );
695
696 it.effect("does not send another shutdown to a stopping runtime", () =>
697 Effect.gen(function* () {
698 state.stopping = true;
699 yield* Effect.promise(() => socket());
700 yield* stop();
701 expect(commands).toEqual([["status", "--json"]]);
702 }),
703 );
704 });
705});