| 1 | import type * as vite from "vite"; |
| 2 | import { assert, describe, expect, it, vi } from "vitest"; |
| 3 | import { parseViteEnvironments, type BasePluginOptions } from "../options.ts"; |
| 4 | import { |
| 5 | cloudflareExternalsPlugin, |
| 6 | nodejsUnenvPlugin, |
| 7 | optionsPlugin, |
| 8 | virtualModulesPlugin, |
| 9 | } from "../plugins/index.ts"; |
| 10 | |
| 11 | const 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 | |
| 23 | const 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 | |
| 36 | describe("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 | }); |