1import type * as vite from "vite";
2import { assert, describe, expect, it, vi } from "vitest";
3import { parseViteEnvironments, type BasePluginOptions } from "../options.ts";
4import {
5 cloudflareExternalsPlugin,
6 nodejsUnenvPlugin,
7 optionsPlugin,
8 virtualModulesPlugin,
9} from "../plugins/index.ts";
10
11const applyToEnvironment = (plugin: vite.Plugin | null, name: string) => {
12 assert(plugin, "plugin is not defined");
13 assert(
14 typeof plugin.applyToEnvironment === "function",
15 "plugin.applyToEnvironment is not a function",
16 );
17 return plugin.applyToEnvironment.call(
18 plugin as never,
19 { name } as unknown as Parameters<typeof plugin.applyToEnvironment>[0],
20 );
21};
22
23const callConfigEnvironment = (plugin: vite.Plugin | null, name: string) => {
24 assert(plugin, "plugin is not defined");
25 const hook = plugin.configEnvironment;
26 assert(hook, "plugin.configEnvironment is not defined");
27 const handler = typeof hook === "function" ? hook : hook.handler;
28 return handler.call(
29 { meta: {} } as never,
30 name,
31 {} as never,
32 { command: "serve", mode: "development" } as never,
33 );
34};
35
36describe("skipEnvironments", () => {
37 const options: BasePluginOptions = {
38 compatibilityDate: "2025-07-01",
39 compatibilityFlags: ["nodejs_compat"],
40 skipEnvironments: ["prerender", "astro"],
41 };
42
43 it("excludes skipped environments (and client) from every per-environment plugin", () => {
44 for (const factory of [
45 optionsPlugin,
46 cloudflareExternalsPlugin,
47 nodejsUnenvPlugin,
48 virtualModulesPlugin,
49 ]) {
50 const plugin = factory.vite(options);
51 expect(applyToEnvironment(plugin, "ssr")).toBe(true);
52 expect(applyToEnvironment(plugin, "client")).toBe(false);
53 expect(applyToEnvironment(plugin, "prerender")).toBe(false);
54 expect(applyToEnvironment(plugin, "astro")).toBe(false);
55 }
56 });
57
58 it("gates configEnvironment away from skipped environments", async () => {
59 const plugin = nodejsUnenvPlugin.vite(options);
60 expect(await callConfigEnvironment(plugin, "prerender")).toBeUndefined();
61 expect(await callConfigEnvironment(plugin, "astro")).toBeUndefined();
62 const ssr = await callConfigEnvironment(plugin, "ssr");
63 expect(ssr).toBeDefined();
64 expect(
65 (ssr as { resolve?: { builtins?: unknown } }).resolve?.builtins,
66 ).toBeDefined();
67 });
68
69 it("keeps the client environment's special configEnvironment handling", async () => {
70 const plugin = cloudflareExternalsPlugin.vite(options);
71 expect(await callConfigEnvironment(plugin, "prerender")).toBeUndefined();
72 const client = (await callConfigEnvironment(plugin, "client")) as {
73 optimizeDeps?: { exclude?: Array<string> };
74 };
75 expect(client?.optimizeDeps?.exclude).toContain("cloudflare:workers");
76 });
77
78 it("rejects worker environments listed in skipEnvironments", () => {
79 expect(() =>
80 parseViteEnvironments({ skipEnvironments: ["ssr"] }),
81 ).toThrowError(/cannot be both a worker environment/);
82 expect(() =>
83 parseViteEnvironments({
84 viteEnvironments: { entry: "rsc", children: ["ssr"] },
85 skipEnvironments: ["ssr"],
86 }),
87 ).toThrowError(/cannot be both a worker environment/);
88 expect(() =>
89 parseViteEnvironments({
90 viteEnvironments: { entry: "rsc", children: ["ssr"] },
91 skipEnvironments: ["prerender"],
92 }),
93 ).not.toThrow();
94 });
95
96 it("tolerates environments whose optimizer does not support registerMissingImport", async () => {
97 const plugin = nodejsUnenvPlugin.vite(options);
98 assert(plugin, "plugin is not defined");
99 const configureServer = plugin.configureServer;
100 assert(
101 typeof configureServer === "function",
102 "configureServer is not a function",
103 );
104
105 const registered: Array<string> = [];
106 const makeEnvironment = (
107 name: string,
108 {
109 noDiscovery = false,
110 throws = false,
111 }: { noDiscovery?: boolean; throws?: boolean } = {},
112 ) => ({
113 name,
114 config: { optimizeDeps: { noDiscovery } },
115 depsOptimizer: {
116 init: async () => {},
117 registerMissingImport: (id: string) => {
118 if (throws) {
119 throw new Error(
120 `registerMissingImport is not supported in dev ${name}`,
121 );
122 }
123 registered.push(`${name}:${id}`);
124 return { id, processing: Promise.resolve() };
125 },
126 },
127 });
128 const server = {
129 environments: {
130 ssr: makeEnvironment("ssr"),
131 // Vite's no-discovery ("explicit") optimizer throws by design.
132 prerender: makeEnvironment("prerender", {
133 noDiscovery: true,
134 throws: true,
135 }),
136 // Skipped environments must not be touched even if their optimizer
137 // looks usable.
138 astro: makeEnvironment("astro", { throws: true }),
139 client: makeEnvironment("client", { throws: true }),
140 // Environments without an optimizer are skipped.
141 bare: {
142 name: "bare",
143 config: { optimizeDeps: {} },
144 depsOptimizer: undefined,
145 },
146 },
147 };
148
149 await expect(
150 configureServer.call(plugin as never, server as never),
151 ).resolves.not.toThrow();
152 expect(registered.length).toBeGreaterThan(0);
153 expect(registered.every((entry) => entry.startsWith("ssr:"))).toBe(true);
154 });
155
156 it("skips the dependency optimizer in resolveId when discovery is disabled", async () => {
157 const plugin = nodejsUnenvPlugin.vite(options);
158 assert(plugin, "plugin is not defined");
159 const hook = plugin.resolveId;
160 assert(
161 hook && typeof hook === "object" && "handler" in hook,
162 "resolveId is not a hook",
163 );
164 const registerMissingImport = vi.fn(() => {
165 throw new Error(
166 "registerMissingImport is not supported in dev prerender",
167 );
168 });
169 const resolve = vi.fn(async (id: string) => ({ id }));
170 const context = {
171 environment: {
172 mode: "dev",
173 config: { optimizeDeps: { noDiscovery: true } },
174 depsOptimizer: { registerMissingImport },
175 },
176 resolve,
177 };
178 // `node:child_process` is polyfilled by unenv (workerd cannot spawn
179 // processes), so the plugin resolves it rather than externalizing it.
180 await (
181 hook.handler as unknown as (
182 this: typeof context,
183 source: string,
184 importer: string | undefined,
185 options: object,
186 ) => Promise<unknown>
187 ).call(context, "node:child_process", undefined, {});
188 expect(registerMissingImport).not.toHaveBeenCalled();
189 // Falls back to resolving the polyfill directly instead of pre-bundling it.
190 expect(resolve).toHaveBeenCalledTimes(1);
191 const [resolvedPath] = resolve.mock.calls[0] ?? [];
192 expect(resolvedPath).not.toBe("node:child_process");
193 });
194});