1import path from "node:path";
2import type { MinimalPluginContext } from "rolldown";
3import type * as vite from "vite";
4import { assert, describe, expect, it } from "vitest";
5import { optionsPlugin } from "../plugins/options.ts";
6
7const callViteConfig = async (
8 plugin: vite.Plugin | null,
9 userConfig: vite.UserConfig,
10): Promise<vite.UserConfig> => {
11 assert(plugin, "plugin is not defined");
12 const hook = plugin.config;
13 assert(typeof hook === "function", "plugin.config is not a function");
14 const result = await hook.call({ meta: {} } as never, userConfig, {
15 command: "build",
16 mode: "production",
17 } as never);
18 assert(result, "config hook returned nothing");
19 return result as vite.UserConfig;
20};
21
22const NO_HTML_ROOT = path.resolve("test/fixtures");
23const HTML_ROOT = path.resolve("test/fixtures/client-html");
24
25interface FakeEnvironment {
26 name: string;
27 isBuilt?: boolean;
28 /** Defaults to a directory with no `index.html`. */
29 root?: string;
30 build?: {
31 rollupOptions?: { input?: unknown };
32 rolldownOptions?: { input?: unknown };
33 };
34}
35
36/** Records the environments the resolved `buildApp` actually builds. */
37const runBuildApp = async (
38 config: vite.UserConfig,
39 environments: Array<FakeEnvironment>,
40): Promise<Array<string>> => {
41 const buildApp = config.builder?.buildApp;
42 assert(typeof buildApp === "function", "buildApp is not a function");
43 const built: Array<string> = [];
44 await buildApp({
45 environments: Object.fromEntries(
46 environments.map(
47 ({ name, isBuilt = false, root = NO_HTML_ROOT, build = {} }) => [
48 name,
49 { name, isBuilt, config: { root, build } },
50 ],
51 ),
52 ),
53 build: async (environment: { name: string }) => {
54 built.push(environment.name);
55 },
56 } as never);
57 return built;
58};
59
60describe("options plugin", () => {
61 it("applies the Cloudflare defaults", () => {
62 const plugin = optionsPlugin.rolldown({});
63 const input = {};
64 assert(
65 typeof plugin.options === "function",
66 "plugin.options is not a function",
67 );
68 const output = plugin.options.call({} as MinimalPluginContext, input);
69 assert(output, "output is not defined");
70 assert(!(output instanceof Promise), "output is a promise");
71
72 expect(output.platform).toBe("neutral");
73 expect(output.resolve?.conditionNames).toEqual([
74 "workerd",
75 "worker",
76 "module",
77 "browser",
78 "production",
79 ]);
80 expect(output.resolve?.mainFields).toEqual([
81 "browser",
82 "module",
83 "jsnext:main",
84 "jsnext",
85 "main",
86 ]);
87 expect(output.resolve?.extensions).toEqual([
88 ".mjs",
89 ".js",
90 ".mts",
91 ".ts",
92 ".jsx",
93 ".tsx",
94 ".json",
95 ".cjs",
96 ".cts",
97 ".ctx",
98 ]);
99 expect(output.transform?.target).toBe("es2024");
100 });
101
102 it("defines production node env values and stubs process.env without nodejs_compat", () => {
103 const plugin = optionsPlugin.rolldown({});
104 const input = {};
105 assert(
106 typeof plugin.options === "function",
107 "plugin.options is not a function",
108 );
109 const output = plugin.options!.call({} as MinimalPluginContext, input);
110 assert(output, "output is not defined");
111 assert(!(output instanceof Promise), "output is a promise");
112
113 expect(output.transform?.define).toMatchObject({
114 "process.env.NODE_ENV": '"production"',
115 "global.process.env.NODE_ENV": '"production"',
116 "globalThis.process.env.NODE_ENV": '"production"',
117 "process.env": "{}",
118 "global.process.env": "{}",
119 "globalThis.process.env": "{}",
120 });
121 });
122
123 it.each([
124 { compatibilityFlags: ["nodejs_compat"] },
125 { compatibilityDate: "2026-08-31" },
126 ])("preserves runtime process.env with Node compatibility: %j", (options) => {
127 const plugin = optionsPlugin.rolldown(options);
128 const input = {};
129 assert(
130 typeof plugin.options === "function",
131 "plugin.options is not a function",
132 );
133 const output = plugin.options!.call({} as MinimalPluginContext, input);
134 assert(output, "output is not defined");
135 assert(!(output instanceof Promise), "output is a promise");
136
137 expect(output.transform?.define).toMatchObject({
138 "process.env.NODE_ENV": '"production"',
139 "global.process.env.NODE_ENV": '"production"',
140 "globalThis.process.env.NODE_ENV": '"production"',
141 });
142 expect(output.transform?.define?.["process.env"]).toBeUndefined();
143 expect(output.transform?.define?.["global.process.env"]).toBeUndefined();
144 expect(
145 output.transform?.define?.["globalThis.process.env"],
146 ).toBeUndefined();
147 });
148
149 it("defines navigator.userAgent only when the compatibility date supports it", () => {
150 const withNavigator = optionsPlugin.rolldown({
151 compatibilityDate: "2022-03-21",
152 });
153 const withoutNavigator = optionsPlugin.rolldown({
154 compatibilityDate: "2022-03-20",
155 });
156
157 assert(
158 typeof withNavigator.options === "function",
159 "withNavigator.options is not a function",
160 );
161 assert(
162 typeof withoutNavigator.options === "function",
163 "withoutNavigator.options is not a function",
164 );
165
166 const withNavigatorOutput = withNavigator.options!.call(
167 {} as MinimalPluginContext,
168 {},
169 );
170 const withoutNavigatorOutput = withoutNavigator.options.call(
171 {} as MinimalPluginContext,
172 {},
173 );
174
175 assert(withNavigatorOutput, "withNavigatorOutput is not defined");
176 assert(
177 !(withNavigatorOutput instanceof Promise),
178 "withNavigatorOutput is a promise",
179 );
180 assert(withoutNavigatorOutput, "withoutNavigatorOutput is not defined");
181 assert(
182 !(withoutNavigatorOutput instanceof Promise),
183 "withoutNavigatorOutput is a promise",
184 );
185
186 expect(withNavigatorOutput.transform?.define?.["navigator.userAgent"]).toBe(
187 '"Cloudflare-Workers"',
188 );
189 expect(
190 withoutNavigatorOutput.transform?.define?.["navigator.userAgent"],
191 ).toBeUndefined();
192 });
193
194 it("normalizes Windows backslashes in worker entry ids", () => {
195 const plugin = optionsPlugin.rolldown({});
196 assert(
197 typeof plugin.options === "function",
198 "plugin.options is not a function",
199 );
200 const output = plugin.options.call({} as MinimalPluginContext, {
201 input: { worker: "D:\\src\\app\\entry-server.ts" },
202 });
203 assert(output, "output is not defined");
204 assert(!(output instanceof Promise), "output is a promise");
205
206 // The virtual worker-entry id must use POSIX separators so it round-trips
207 // through Rolldown/Vite id handling and resolves on Windows.
208 expect(output.input).toEqual({
209 worker: "\0distilled:worker-entry:D:/src/app/entry-server.ts",
210 });
211 });
212
213 it("defaults builder.buildApp when the user config does not define one", async () => {
214 const plugin = optionsPlugin.vite({ main: "./worker.ts" });
215 const result = await callViteConfig(plugin, {});
216 expect(typeof result.builder?.buildApp).toBe("function");
217 });
218
219 it("does not clobber a framework-provided builder.buildApp", async () => {
220 // Vite merges plugin `config` results over the user config, so returning a
221 // default here would replace the framework's orchestrator (e.g. Astro's
222 // prerender -> ssr -> client pipeline).
223 const buildApp = async () => {};
224 const plugin = optionsPlugin.vite({ main: "./worker.ts" });
225 const result = await callViteConfig(plugin, { builder: { buildApp } });
226 expect(result.builder).toBeUndefined();
227 });
228
229 it("does not provide a builder in SPA mode", async () => {
230 const plugin = optionsPlugin.vite({});
231 const result = await callViteConfig(plugin, {});
232 expect(result.appType).toBe("spa");
233 expect(result.builder).toBeUndefined();
234 });
235
236 it("skips skipEnvironments in the default buildApp", async () => {
237 const plugin = optionsPlugin.vite({
238 main: "./worker.ts",
239 skipEnvironments: ["prerender"],
240 });
241 const result = await callViteConfig(plugin, {});
242
243 const built = await runBuildApp(result, [
244 { name: "ssr" },
245 { name: "client", build: { rollupOptions: { input: "./src/main.tsx" } } },
246 // Framework-owned environment with no build input of its own.
247 { name: "prerender" },
248 { name: "already", isBuilt: true },
249 ]);
250 expect(built).toEqual(["ssr", "client"]);
251 });
252
253 it("skips the entry-less client environment in the default buildApp", async () => {
254 // Vite creates `client` for every app. A pure Worker app never configures
255 // it, so building it would fall back to a nonexistent `index.html`.
256 const plugin = optionsPlugin.vite({ main: "./worker.ts" });
257 const result = await callViteConfig(plugin, {});
258
259 const built = await runBuildApp(result, [
260 { name: "ssr" },
261 { name: "client" },
262 ]);
263 expect(built).toEqual(["ssr"]);
264 });
265
266 it("builds a client environment that has a configured input", async () => {
267 const plugin = optionsPlugin.vite({ main: "./worker.ts" });
268 const result = await callViteConfig(plugin, {});
269
270 const withRollupInput = await runBuildApp(result, [
271 { name: "client", build: { rollupOptions: { input: "./src/main.tsx" } } },
272 ]);
273 expect(withRollupInput).toEqual(["client"]);
274
275 const withRolldownInput = await runBuildApp(result, [
276 {
277 name: "client",
278 build: { rolldownOptions: { input: "./src/main.tsx" } },
279 },
280 ]);
281 expect(withRolldownInput).toEqual(["client"]);
282 });
283
284 it("builds a client environment whose root has an index.html", async () => {
285 const plugin = optionsPlugin.vite({ main: "./worker.ts" });
286 const result = await callViteConfig(plugin, {});
287
288 const built = await runBuildApp(result, [
289 { name: "client", root: HTML_ROOT },
290 ]);
291 expect(built).toEqual(["client"]);
292 });
293});