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 type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
7import { describe, expect, it } from "vitest";
8import { resolveConfigPath, runOpenNextBuild } from "../Runner.ts";
9import source from "../source.ts";
10
11const run = <A, E>(
12 effect: Effect.Effect<
13 A,
14 E,
15 | FileSystem.FileSystem
16 | Path.Path
17 | Scope.Scope
18 | ChildProcessSpawner.ChildProcessSpawner
19 >,
20) =>
21 Effect.runPromise(
22 Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)),
23 );
24
25const fixture = Effect.gen(function* () {
26 const fs = yield* FileSystem.FileSystem;
27 const path = yield* Path.Path;
28 const root = yield* fs.makeTempDirectoryScoped({
29 directory: yield* path.fromFileUrl(new URL("../../../", import.meta.url)),
30 prefix: ".nextjs-config-test-",
31 });
32 return { fs, path, root };
33});
34
35describe("optional OpenNext configuration", () => {
36 it("generates defaults only when the native config is absent", () =>
37 run(
38 Effect.gen(function* () {
39 const { fs, path, root } = yield* fixture;
40 expect(yield* resolveConfigPath({ appDir: root })).toBeUndefined();
41 expect(yield* fs.readDirectory(root)).toEqual([]);
42 const configPath = path.join(root, "open-next.config.ts");
43 yield* fs.writeFileString(
44 configPath,
45 "export default { default: {} };\n",
46 );
47 expect(yield* resolveConfigPath({ appDir: root })).toBe(configPath);
48 expect(yield* fs.readFileString(configPath)).toBe(
49 "export default { default: {} };\n",
50 );
51 }),
52 ));
53
54 it("prefers an explicit file and rejects a missing explicit path", () =>
55 run(
56 Effect.gen(function* () {
57 const { fs, path, root } = yield* fixture;
58 yield* fs.writeFileString(
59 path.join(root, "open-next.config.ts"),
60 "native",
61 );
62 yield* fs.writeFileString(path.join(root, "custom.mjs"), "custom");
63 expect(
64 yield* resolveConfigPath({ appDir: root, configPath: "custom.mjs" }),
65 ).toBe(path.join(root, "custom.mjs"));
66 const error = yield* resolveConfigPath({
67 appDir: root,
68 configPath: "missing.ts",
69 }).pipe(Effect.flip);
70 expect(error.message).toContain("OpenNext config file not found");
71 }),
72 ));
73
74 it("rejects a directory instead of treating it as absent", () =>
75 run(
76 Effect.gen(function* () {
77 const { fs, path, root } = yield* fixture;
78 yield* fs.makeDirectory(path.join(root, "open-next.config.ts"));
79 const error = yield* resolveConfigPath({ appDir: root }).pipe(
80 Effect.flip,
81 );
82 expect(error.message).toContain("OpenNext config is not a file");
83 }),
84 ));
85
86 it("hashes config creation, edits, and removal even outside a narrowed memo scope", () =>
87 run(
88 Effect.gen(function* () {
89 const { fs, path, root } = yield* fixture;
90 const provider = yield* source.make({
91 root,
92 memo: { include: ["app/**"] },
93 });
94 const context = {
95 id: "Site",
96 workerName: "site",
97 compatibility: { date: "2026-08-31", flags: [] },
98 };
99 const hash = () => provider.hash(context, undefined);
100 const absent = yield* hash();
101 const configPath = path.join(root, "open-next.config.ts");
102 yield* fs.writeFileString(
103 configPath,
104 "export default { default: {} };\n",
105 );
106 const created = yield* hash();
107 expect(created.input).not.toBe(absent.input);
108 expect((yield* hash()).input).toBe(created.input);
109 const relocated = yield* fixture;
110 yield* fs.writeFileString(
111 relocated.path.join(relocated.root, "open-next.config.ts"),
112 "export default { default: {} };\n",
113 );
114 const relocatedProvider = yield* source.make({
115 root: relocated.root,
116 memo: { include: ["app/**"] },
117 });
118 expect((yield* relocatedProvider.hash(context, undefined)).input).toBe(
119 created.input,
120 );
121 yield* fs.writeFileString(
122 configPath,
123 "export default { default: { minify: true } };\n",
124 );
125 expect((yield* hash()).input).not.toBe(created.input);
126 yield* fs.remove(configPath);
127 expect((yield* hash()).input).toBe(absent.input);
128 }),
129 ));
130
131 it("cleans parent-owned temporary configuration when the child fails", () =>
132 run(
133 Effect.gen(function* () {
134 const { fs, root } = yield* fixture;
135 const directories: string[] = [];
136 const scratchFiles: string[] = [];
137 const tracked = {
138 ...fs,
139 makeTempDirectoryScoped: (
140 options?: Parameters<typeof fs.makeTempDirectoryScoped>[0],
141 ) =>
142 fs.makeTempDirectoryScoped(options).pipe(
143 Effect.tap((directory) =>
144 Effect.gen(function* () {
145 directories.push(directory);
146 yield* Effect.addFinalizer(() =>
147 fs.readDirectory(directory).pipe(
148 Effect.tap((files) =>
149 Effect.sync(() => {
150 scratchFiles.push(...files);
151 }),
152 ),
153 Effect.ignore,
154 ),
155 );
156 }),
157 ),
158 ),
159 };
160 const error = yield* runOpenNextBuild({
161 appDir: root,
162 compatibilityDate: "2026-08-31",
163 }).pipe(
164 Effect.provideService(FileSystem.FileSystem, tracked),
165 Effect.flip,
166 );
167 expect(error._tag).toBe("RunnerError");
168 expect(directories).toHaveLength(1);
169 expect(scratchFiles).toContain("open-next.config.mjs");
170 expect(
171 scratchFiles.some((file) => file.startsWith("open-next-tmp")),
172 ).toBe(true);
173 for (const directory of directories)
174 expect(yield* fs.exists(directory)).toBe(false);
175 }),
176 ));
177});