1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import type * as Scope from "effect/Scope";
6import * as NodeFs from "node:fs";
7import { describe, expect, it } from "vitest";
8import { makeDeployTarget } from "../../core/index.ts";
9import { makeAwsTarget } from "../aws.ts";
10import { make, readViteOutput } from "../Vite.ts";
11
12const runWithNode = <A, E>(
13 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
14): Promise<A> =>
15 Effect.runPromise(
16 Effect.scoped(effect).pipe(
17 Effect.provide(NodeServices.layer),
18 ) as Effect.Effect<A, E>,
19 );
20
21/** A minimal html-entry Vite project written into a scoped temp directory. */
22const writeFixtureProject = Effect.fn(function* (marker: string) {
23 const fs = yield* FileSystem.FileSystem;
24 const path = yield* Path.Path;
25 const root = yield* fs.makeTempDirectoryScoped({ prefix: "vite-app-" });
26 yield* fs.writeFileString(
27 path.join(root, "package.json"),
28 JSON.stringify({ name: "vite-fixture", private: true, type: "module" }),
29 );
30 yield* fs.writeFileString(
31 path.join(root, "index.html"),
32 `<!doctype html><html><head><title>fixture</title></head><body><div id="app">${marker}</div><script type="module" src="/src/main.ts"></script></body></html>`,
33 );
34 yield* fs.makeDirectory(path.join(root, "src"), { recursive: true });
35 yield* fs.writeFileString(
36 path.join(root, "src", "main.ts"),
37 `document.querySelector("#app")!.setAttribute("data-ready", "true");\n`,
38 );
39 return root;
40});
41
42/**
43 * Fully canonical form of an existing path. Effect's `fs.realPath` delegates
44 * to node's `fs.realpath`, which resolves symlinks (macOS `/var` ->
45 * `/private/var`) but NOT Windows 8.3 short names (`RUNNER~1` vs
46 * `runneradmin`); only `realpath.native` does both.
47 */
48const canonicalPath = (p: string) =>
49 Effect.sync(() => NodeFs.realpathSync.native(p));
50
51/** An adapter-less in-process target (no build child) for direct testing. */
52const inProcessTarget = makeDeployTarget({
53 platform: "aws",
54 config: {},
55});
56
57describe("readViteOutput", () => {
58 it("maps an assets directory onto the assets-only BuildOutput contract", async () => {
59 const output = await runWithNode(
60 Effect.gen(function* () {
61 const fs = yield* FileSystem.FileSystem;
62 const path = yield* Path.Path;
63 const dir = yield* fs.makeTempDirectoryScoped({
64 prefix: "vite-output-",
65 });
66 yield* fs.writeFileString(
67 path.join(dir, "index.html"),
68 "<!doctype html>",
69 );
70 return yield* readViteOutput({ outDir: dir }).pipe(
71 Effect.map((output) => ({
72 distDirectory: output.distDirectory,
73 clientDirectory: output.clientDirectory,
74 serverModules: output.serverModules,
75 externalWorkspaces: [...output.externalWorkspaces],
76 dir,
77 })),
78 );
79 }),
80 );
81 expect(output.distDirectory).toBe(output.dir);
82 expect(output.clientDirectory).toBe(output.dir);
83 expect(output.serverModules).toBeUndefined();
84 expect(output.externalWorkspaces).toEqual([]);
85 });
86
87 it("fails actionably when the output directory is missing", async () => {
88 const error = await runWithNode(
89 Effect.flip(readViteOutput({ outDir: "/nonexistent/vite-dist" })),
90 );
91 expect(error._tag).toBe("FrameworkError");
92 expect(error.message).toContain("no output directory");
93 });
94});
95
96describe("make().build", () => {
97 it("drives the project's vite build and returns an assets-only output", async () => {
98 const result = await runWithNode(
99 Effect.gen(function* () {
100 const fs = yield* FileSystem.FileSystem;
101 const path = yield* Path.Path;
102 const root = yield* writeFixtureProject("VITE_BUILD_MARKER");
103 const framework = yield* make({ root, target: inProcessTarget });
104 const output = yield* framework.build({ root });
105 const indexHtml = yield* fs.readFileString(
106 path.join(output.clientDirectory!, "index.html"),
107 );
108 // vite's resolveConfig canonicalizes the root, so compare fully
109 // canonical paths on both sides.
110 return {
111 clientDirectory: yield* canonicalPath(output.clientDirectory!),
112 distDirectory: yield* canonicalPath(output.distDirectory!),
113 serverModules: output.serverModules,
114 indexHtml,
115 expectedOutDir: yield* canonicalPath(path.join(root, "dist")),
116 };
117 }),
118 );
119 expect(result.clientDirectory).toBe(result.expectedOutDir);
120 expect(result.distDirectory).toBe(result.expectedOutDir);
121 expect(result.serverModules).toBeUndefined();
122 expect(result.indexHtml).toContain("VITE_BUILD_MARKER");
123 }, 120_000);
124
125 it("honors an explicit outDir override", async () => {
126 const result = await runWithNode(
127 Effect.gen(function* () {
128 const fs = yield* FileSystem.FileSystem;
129 const path = yield* Path.Path;
130 const root = yield* writeFixtureProject("VITE_OUTDIR_MARKER");
131 const framework = yield* make({
132 root,
133 target: inProcessTarget,
134 vite: { outDir: "build-output" },
135 });
136 const output = yield* framework.build({ root });
137 return {
138 clientDirectory: output.clientDirectory,
139 expected: path.join(root, "build-output"),
140 exists: yield* fs.exists(
141 path.join(root, "build-output", "index.html"),
142 ),
143 };
144 }),
145 );
146 expect(result.clientDirectory).toBe(result.expected);
147 expect(result.exists).toBe(true);
148 }, 120_000);
149});
150
151describe("make().dev", () => {
152 it("serves the project through vite's own dev server, scoped", async () => {
153 const body = await runWithNode(
154 Effect.gen(function* () {
155 const root = yield* writeFixtureProject("VITE_DEV_MARKER");
156 const framework = yield* make({ root, target: inProcessTarget });
157 const { url } = yield* framework.dev({ root });
158 expect(url).toMatch(/^http:\/\/(localhost|127\.0\.0\.1)/);
159 return yield* Effect.tryPromise(async () => {
160 const response = await fetch(url);
161 return await response.text();
162 });
163 }),
164 );
165 expect(body).toContain("VITE_DEV_MARKER");
166 }, 120_000);
167});
168
169describe("makeAwsTarget", () => {
170 it("is an assets-only target whose only seam is the wholesale child build", () => {
171 const target = makeAwsTarget({ vite: { outDir: "dist" } });
172 expect(target.platform).toBe("aws");
173 expect(target.build).toBeTypeOf("function");
174 expect(target.finish).toBeUndefined();
175 expect(target.entry).toBeUndefined();
176 expect(target.config).toEqual({ vite: { outDir: "dist" } });
177 });
178});