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 { describe, expect, it } from "vitest";
7import { fromHarnessOptions } from "../index.ts";
8import { make, readNitroOutput, SERVER_ENTRY_NAME } from "../Nuxt.ts";
9
10const runWithNode = <A, E>(
11 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
12): Promise<A> =>
13 Effect.runPromise(
14 Effect.scoped(effect).pipe(
15 Effect.provide(NodeServices.layer),
16 ) as Effect.Effect<A, E>,
17 );
18
19describe("readNitroOutput", () => {
20 it("maps a nitro .output tree onto the BuildOutput contract, entry first", async () => {
21 const output = await runWithNode(
22 Effect.gen(function* () {
23 const fs = yield* FileSystem.FileSystem;
24 const path = yield* Path.Path;
25 const dir = yield* fs.makeTempDirectoryScoped({
26 prefix: "nuxt-output-",
27 });
28 const serverDir = path.join(dir, "server");
29 const publicDir = path.join(dir, "public");
30 yield* fs.makeDirectory(path.join(serverDir, "chunks"), {
31 recursive: true,
32 });
33 yield* fs.makeDirectory(publicDir, { recursive: true });
34 // alphabetically before index.mjs, to prove entry-first sorting
35 yield* fs.writeFileString(
36 path.join(serverDir, "chunks", "a.mjs"),
37 "export const a = 1;",
38 );
39 yield* fs.writeFileString(
40 path.join(serverDir, "index.mjs"),
41 "export default {};",
42 );
43 yield* fs.writeFileString(
44 path.join(publicDir, "robots.txt"),
45 "User-agent: *\n",
46 );
47 return yield* readNitroOutput({ dir, serverDir, publicDir });
48 }),
49 );
50 expect(output.serverModules?.map((module) => module.name)).toEqual([
51 SERVER_ENTRY_NAME,
52 "server/chunks/a.mjs",
53 ]);
54 expect(output.serverModules?.[0]?.hash).toMatch(/^[0-9a-f]{64}$/);
55 expect(output.clientDirectory).toBeDefined();
56 expect(output.distDirectory).toBeDefined();
57 expect(output.externalWorkspaces.size).toBe(0);
58 });
59
60 it("fails when the server directory has no index.mjs entry", async () => {
61 const result = await runWithNode(
62 Effect.gen(function* () {
63 const fs = yield* FileSystem.FileSystem;
64 const path = yield* Path.Path;
65 const dir = yield* fs.makeTempDirectoryScoped({
66 prefix: "nuxt-output-",
67 });
68 const serverDir = path.join(dir, "server");
69 yield* fs.makeDirectory(serverDir, { recursive: true });
70 yield* fs.writeFileString(
71 path.join(serverDir, "other.mjs"),
72 "export const x = 1;",
73 );
74 return yield* Effect.result(
75 readNitroOutput({
76 dir,
77 serverDir,
78 publicDir: path.join(dir, "public"),
79 }),
80 );
81 }),
82 );
83 expect(result._tag).toBe("Failure");
84 if (result._tag === "Failure") {
85 expect(String(result.failure)).toContain("server/index.mjs");
86 }
87 });
88});
89
90describe("make", () => {
91 it("dev fails with a descriptive FrameworkError outside a Nuxt project", async () => {
92 // /tmp has no nuxt install: resolving the project's kit is the first
93 // step of `dev` and must surface a FrameworkError (not a raw throw).
94 const result = await runWithNode(
95 Effect.gen(function* () {
96 const framework = yield* make({ root: "/tmp/does-not-matter" });
97 return yield* Effect.result(framework.dev());
98 }),
99 );
100 expect(result._tag).toBe("Failure");
101 if (result._tag === "Failure") {
102 expect(result.failure._tag).toBe("FrameworkError");
103 }
104 });
105});
106
107describe("fromHarnessOptions", () => {
108 it("prefers the target-scoped carriage over the deprecated vite alias", () => {
109 const options = fromHarnessOptions({
110 target: {
111 cloudflare: {
112 worker: {
113 compatibilityDate: "2026-03-10",
114 compatibilityFlags: ["nodejs_compat"],
115 main: "./worker-entry.ts",
116 },
117 },
118 },
119 vite: { compatibilityDate: "1999-01-01" },
120 });
121 expect(options.compatibilityDate).toBe("2026-03-10");
122 expect(options.compatibilityFlags).toEqual(["nodejs_compat"]);
123 expect(options.main).toBe("./worker-entry.ts");
124 });
125
126 it("falls back to the vite alias when no target carriage exists", () => {
127 const options = fromHarnessOptions({
128 vite: { compatibilityDate: "2026-03-10" },
129 });
130 expect(options.compatibilityDate).toBe("2026-03-10");
131 expect(options.main).toBeUndefined();
132 });
133});