1import { Interaction, layerNonInteractive, accessors } from "@/Interaction.ts";
2import { nodeLoaderArgs } from "@/Util/Node.ts";
3import { PlatformServices } from "@/Util/PlatformServices.ts";
4import { expect, it } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as Stream from "effect/Stream";
7import * as ChildProcess from "effect/process/ChildProcess";
8import { PassThrough } from "node:stream";
9import { fileURLToPath } from "node:url";
10import { nodePath, nodeSupportsDevMode } from "./nodeProbe.ts";
11
12class CaptureStream extends PassThrough {
13 readonly columns = 80;
14 readonly rows = 24;
15 readonly isTTY = false;
16 output = "";
17
18 constructor() {
19 super();
20 this.on("data", (chunk) => {
21 this.output += chunk.toString();
22 });
23 }
24}
25
26const nonInteractive = (stdout: CaptureStream) =>
27 layerNonInteractive({ stdout: stdout as unknown as NodeJS.WriteStream });
28
29it(
30 "prints plain status lines and fails prompts typed",
31 () => {
32 const stdout = new CaptureStream();
33 return Effect.gen(function* () {
34 const interaction = yield* Interaction;
35 yield* interaction.output.info({
36 message: "plain info",
37 detail: "extra",
38 });
39 yield* accessors.output.success("plain success");
40 const failure = yield* Effect.flip(
41 interaction.prompt.confirm({ message: "proceed?" }),
42 );
43 expect(failure._tag).toBe("NonInteractiveTerminal");
44 const select = yield* Effect.flip(
45 accessors.prompt.select({ message: "pick", options: [] }),
46 );
47 expect(select._tag).toBe("NonInteractiveTerminal");
48 const lines = stdout.output.split("\n");
49 expect(lines[0]).toContain("plain info");
50 expect(lines[0]).toContain("· extra");
51 expect(lines[1]).toContain("plain success");
52 }).pipe(Effect.provide(nonInteractive(stdout)));
53 },
54 { tags: ["unit", "local"] },
55);
56
57it(
58 "task prints a start line and settles with a status line",
59 () => {
60 const stdout = new CaptureStream();
61 return Effect.gen(function* () {
62 const interaction = yield* Interaction;
63 yield* interaction.task(
64 { label: "working", detail: "step" },
65 Effect.void,
66 );
67 const failed = yield* Effect.result(
68 interaction.task({ label: "breaking" }, Effect.fail("boom")),
69 );
70 expect(failed._tag).toBe("Failure");
71 const lines = stdout.output.trimEnd().split("\n");
72 expect(lines[0]).toContain("working");
73 expect(lines[0]).toContain("· step");
74 expect(lines[1]).toContain("working");
75 expect(lines[2]).toContain("breaking");
76 expect(lines[3]).toContain("breaking");
77 }).pipe(Effect.provide(nonInteractive(stdout)));
78 },
79 { tags: ["unit", "local"] },
80);
81
82// The regression this pins: spawned dev children carry NO interaction
83// services at all, so the vite child runner's entire module graph must load
84// under node (the same dev-mode loader `ViteChild.ts` spawns it with) and
85// proceed to config resolution — the original bug was an eager CliKit import
86// killing every `runtime: "node"` dev child at startup.
87it.live.skipIf(!nodeSupportsDevMode)(
88 "the vite child runner's module graph loads under node",
89 () =>
90 Effect.gen(function* () {
91 const runner = fileURLToPath(
92 new URL(
93 "../src/Cloudflare/Workers/ViteChildRunner.ts",
94 import.meta.url,
95 ),
96 );
97 const handle = yield* ChildProcess.make(
98 nodePath!,
99 [...nodeLoaderArgs(runner), runner],
100 {
101 cwd: fileURLToPath(new URL("..", import.meta.url)),
102 env: { NO_COLOR: "1" },
103 extendEnv: true,
104 stdin: "ignore",
105 stdout: "pipe",
106 stderr: "pipe",
107 killSignal: "SIGKILL",
108 },
109 );
110 const [stdout, stderr] = yield* Effect.all(
111 [
112 handle.stdout.pipe(
113 Stream.decodeText,
114 Stream.runCollect,
115 Effect.map((chunks) => chunks.join("")),
116 ),
117 handle.stderr.pipe(
118 Stream.decodeText,
119 Stream.runCollect,
120 Effect.map((chunks) => chunks.join("")),
121 ),
122 // Exit code is irrelevant — with no stdin config the runner is
123 // EXPECTED to fail; what matters is WHERE it fails.
124 handle.exitCode,
125 ],
126 { concurrency: 3 },
127 );
128 const combined = `${stdout}\n${stderr}`;
129 expect(combined).not.toContain("Unknown file extension");
130 expect(combined).not.toContain("ERR_MODULE_NOT_FOUND");
131 // Reaching environment/config resolution proves the whole module
132 // graph (auth providers, credentials, vite loader) parsed under node.
133 expect(combined).toContain("ALCHEMY_RPC_SERVER_ENVIRONMENT");
134 }).pipe(Effect.scoped, Effect.provide(PlatformServices)),
135 { tags: ["unit", "local"], timeout: 60_000 },
136);