1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as NodeFsPromises from "node:fs/promises";
4import * as NodePath from "node:path";
5import type * as ViteModule from "vite";
6import { describe, expect, it } from "vitest";
7import { mergeConfig } from "vite";
8import framework, {
9 DEFAULT_TARGET_SPECIFIER,
10 layer,
11 make,
12 makeWakuConfigInput,
13 makeWakuServerEntryPlugin,
14 mergeUserWakuConfig,
15 selectWakuTargetInput,
16 WAKU_CONFIG_FILES,
17 WAKU_SERVER_ENTRY_ID,
18 WAKU_SERVER_ENTRY_MODULE,
19 type WakuTarget,
20} from "../index.ts";
21
22const ADAPTER =
23 "/project/node_modules/@alchemy.run/frontend-frameworks/dist/waku/adapter.js";
24
25const flatten = (
26 plugins: Array<ViteModule.PluginOption> | undefined,
27): Array<ViteModule.Plugin> =>
28 ((plugins ?? []) as Array<unknown>)
29 .flat(8)
30 .filter(
31 (plugin): plugin is ViteModule.Plugin =>
32 typeof plugin === "object" && plugin !== null && "name" in plugin,
33 );
34
35describe("makeWakuConfigInput", () => {
36 it("defaults unstable_adapter to the target's adapter module", () => {
37 const config = makeWakuConfigInput({ adapterPath: ADAPTER });
38 expect(config.unstable_adapter).toBe(ADAPTER);
39 });
40
41 it("pins unstable_adapter to the target's adapter module (the caller rejects user adapters)", () => {
42 const config = makeWakuConfigInput({
43 adapterPath: ADAPTER,
44 userConfig: { unstable_adapter: "/custom/adapter.ts" },
45 });
46 expect(config.unstable_adapter).toBe(ADAPTER);
47 });
48
49 it("injects the target's vite plugins ahead of user plugins", () => {
50 const targetPlugin: ViteModule.Plugin = { name: "target-plugin" };
51 const userPlugin: ViteModule.Plugin = { name: "user-plugin" };
52 const config = makeWakuConfigInput({
53 adapterPath: ADAPTER,
54 plugins: [targetPlugin],
55 userConfig: { vite: { plugins: [userPlugin] } },
56 });
57 const plugins = flatten(config.vite?.plugins);
58 const targetIndex = plugins.findIndex(
59 (plugin) => plugin.name === "target-plugin",
60 );
61 const userIndex = plugins.findIndex(
62 (plugin) => plugin.name === "user-plugin",
63 );
64 expect(targetIndex).toBeGreaterThanOrEqual(0);
65 expect(userIndex).toBeGreaterThan(targetIndex);
66 });
67
68 it("injects no plugins when the target provides none", () => {
69 const config = makeWakuConfigInput({ adapterPath: ADAPTER });
70 expect(flatten(config.vite?.plugins)).toEqual([]);
71 });
72
73 it("dedupes waku and hono so the adapter resolves the project's copies", () => {
74 const config = makeWakuConfigInput({
75 adapterPath: ADAPTER,
76 userConfig: { vite: { resolve: { dedupe: ["react", "waku"] } } },
77 });
78 expect(config.vite?.resolve?.dedupe).toEqual(["waku", "hono", "react"]);
79 });
80
81 it("applies the rsc/ssr optimizeDeps includes and neutral platform", () => {
82 const config = makeWakuConfigInput({ adapterPath: ADAPTER });
83 const environments = config.vite?.environments as Record<
84 string,
85 ViteModule.EnvironmentOptions
86 >;
87 expect(environments.rsc?.optimizeDeps?.include).toContain("hono/tiny");
88 expect(environments.ssr?.optimizeDeps?.include).toContain(
89 "waku > rsc-html-stream/server",
90 );
91 expect(environments.rsc?.build?.rolldownOptions?.platform).toBe("neutral");
92 expect(environments.ssr?.build?.rolldownOptions?.platform).toBe("neutral");
93 });
94
95 it("merges user environment config instead of clobbering it", () => {
96 const config = makeWakuConfigInput({
97 adapterPath: ADAPTER,
98 userConfig: {
99 vite: {
100 environments: {
101 rsc: { optimizeDeps: { include: ["extra-dep"] } },
102 custom: { optimizeDeps: { include: ["custom-dep"] } },
103 },
104 },
105 },
106 });
107 const environments = config.vite?.environments as Record<
108 string,
109 ViteModule.EnvironmentOptions
110 >;
111 expect(environments.rsc?.optimizeDeps?.include).toEqual([
112 "hono/tiny",
113 "extra-dep",
114 ]);
115 expect(environments.custom?.optimizeDeps?.include).toEqual(["custom-dep"]);
116 });
117});
118
119describe("mergeUserWakuConfig", () => {
120 const merge = (
121 file: Parameters<typeof mergeUserWakuConfig>[0]["file"],
122 inline: Parameters<typeof mergeUserWakuConfig>[0]["inline"],
123 ) => mergeUserWakuConfig({ file, inline, mergeViteConfig: mergeConfig });
124
125 it("probes the same config files as waku's CLI, in the same order", () => {
126 expect(WAKU_CONFIG_FILES).toEqual(["waku.config.ts", "waku.config.js"]);
127 });
128
129 it("passes a lone file config or lone inline config through", () => {
130 expect(merge({ basePath: "/docs/" }, undefined)).toStrictEqual({
131 basePath: "/docs/",
132 });
133 expect(merge(undefined, { srcDir: "app" })).toStrictEqual({
134 srcDir: "app",
135 });
136 });
137
138 it("merges inline options over the file config per key", () => {
139 expect(
140 merge({ basePath: "/docs/", srcDir: "app" }, { srcDir: "source" }),
141 ).toEqual({
142 basePath: "/docs/",
143 srcDir: "source",
144 });
145 });
146
147 it("does not let explicit undefined inline keys clobber file values", () => {
148 expect(
149 merge({ srcDir: "app" }, { srcDir: undefined, distDir: "out" }),
150 ).toEqual({
151 srcDir: "app",
152 distDir: "out",
153 });
154 });
155
156 it("combines vite configs with vite's mergeConfig (file plugins first)", () => {
157 const filePlugin = { name: "file-plugin" };
158 const inlinePlugin = { name: "inline-plugin" };
159 const merged = merge(
160 { vite: { plugins: [filePlugin], base: "/docs/" } },
161 { vite: { plugins: [inlinePlugin] } },
162 );
163 expect(merged?.vite?.base).toBe("/docs/");
164 const names = flatten(merged?.vite?.plugins).map((plugin) => plugin.name);
165 expect(names.indexOf("file-plugin")).toBeGreaterThanOrEqual(0);
166 expect(names.indexOf("inline-plugin")).toBeGreaterThan(
167 names.indexOf("file-plugin"),
168 );
169 });
170
171 it("keeps the only vite config present", () => {
172 const vite = { base: "/docs/" };
173 expect(merge({ vite }, { srcDir: "app" })?.vite).toBe(vite);
174 expect(merge({ srcDir: "app" }, { vite })?.vite).toBe(vite);
175 });
176});
177
178describe("selectWakuTargetInput", () => {
179 const makeTarget = (config: unknown): WakuTarget => ({
180 platform: "test",
181 config,
182 adapter: () => Effect.succeed("/adapter.js"),
183 vitePlugins: () => Effect.succeed([]),
184 });
185
186 it("defaults to the cloudflare target module specifier", () => {
187 const { input, config } = selectWakuTargetInput();
188 expect(input).toBe(DEFAULT_TARGET_SPECIFIER);
189 expect(config).toBeUndefined();
190 });
191
192 it("feeds the deprecated vite alias to the default target", () => {
193 const vite = { compatibilityDate: "2026-03-10" };
194 const { input, config } = selectWakuTargetInput({ vite });
195 expect(input).toBe(DEFAULT_TARGET_SPECIFIER);
196 expect(config).toBe(vite);
197 });
198
199 it("reads the harness target-scoped carriage (worker wins over the vite alias)", () => {
200 const worker = { compatibilityDate: "2026-03-10" };
201 const { input, config } = selectWakuTargetInput({
202 target: { name: "cloudflare", cloudflare: { worker } },
203 vite: { compatibilityDate: "1999-01-01" },
204 });
205 expect(input).toBe(DEFAULT_TARGET_SPECIFIER);
206 expect(config).toBe(worker);
207 });
208
209 it("falls back to the vite alias when the carriage has no worker config", () => {
210 const vite = { compatibilityDate: "2026-03-10" };
211 const { input, config } = selectWakuTargetInput({
212 target: { name: "cloudflare" },
213 vite,
214 });
215 expect(input).toBe(DEFAULT_TARGET_SPECIFIER);
216 expect(config).toBe(vite);
217 });
218
219 it("passes an explicit target value through unchanged", () => {
220 const target = makeTarget({ worker: { name: "explicit" } });
221 const { input } = selectWakuTargetInput({ target });
222 expect(input).toBe(target);
223 });
224
225 it("passes a target factory through with the vite alias as its config", () => {
226 const factory = (config: unknown) => makeTarget(config);
227 const vite = { compatibilityDate: "2026-03-10" };
228 const { input, config } = selectWakuTargetInput({ target: factory, vite });
229 expect(input).toBe(factory);
230 expect(config).toBe(vite);
231 });
232
233 it("passes a target module specifier through", () => {
234 const { input } = selectWakuTargetInput({ target: "@my/waku-aws" });
235 expect(input).toBe("@my/waku-aws");
236 });
237});
238
239describe("adapter fork", () => {
240 it("drops the wrangler-writing build enhancer and never imports wrangler", async () => {
241 const source = await NodeFsPromises.readFile(
242 NodePath.resolve(import.meta.dirname, "../adapter.ts"),
243 "utf8",
244 );
245 expect(source).toContain("buildEnhancers: []");
246 expect(source).not.toContain("cloudflare-build-enhancer");
247 expect(source).not.toMatch(/from\s+["']wrangler/);
248 expect(source).not.toMatch(/import\(["']wrangler/);
249 });
250});
251
252describe("framework factory", () => {
253 it("default-exports a factory producing a Layer<Framework>", () => {
254 expect(Layer.isLayer(framework({}))).toBe(true);
255 expect(Layer.isLayer(layer())).toBe(true);
256 // The alchemy-side FrameworkModule contract: `make` returns the service
257 // implementation as an Effect.
258 expect(Effect.isEffect(make())).toBe(true);
259 });
260
261 it("pins the server entry to waku's rsc index module", () => {
262 expect(WAKU_SERVER_ENTRY_MODULE).toBe(NodePath.join("server", "index.js"));
263 });
264});
265
266describe("virtual:waku/server-entry", () => {
267 it("resolves the wrappable-handler id to the installed waku server entry", () => {
268 const wakuDirectory = "/project/node_modules/waku";
269 const plugin = makeWakuServerEntryPlugin(wakuDirectory);
270 const resolveId = plugin.resolveId as (id: string) => string | undefined;
271 expect(resolveId(WAKU_SERVER_ENTRY_ID)).toBe(
272 NodePath.join(wakuDirectory, "dist/lib/vite-entries/entry.server.js"),
273 );
274 expect(resolveId("virtual:waku/other")).toBeUndefined();
275 expect(resolveId("./src/worker-entry.ts")).toBeUndefined();
276 });
277});