1import { Framework, type BuildOutput } from "../../core/index.ts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import type { AstroIntegration } from "astro";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as NodeFsPromises from "node:fs/promises";
7import * as NodePath from "node:path";
8import type * as ViteModule from "vite";
9import { describe, expect, it } from "vitest";
10import cloudflareTarget, {
11 distilledCloudflare,
12 IMAGE_PASSTHROUGH_ENDPOINT,
13 makeIntegrationPluginOptions,
14 SERVER_ENTRYPOINT,
15} from "../cloudflare.ts";
16import { createConfigPlugin } from "../config-plugin.ts";
17import framework, {
18 DEFAULT_TARGET_SPECIFIER,
19 isAstroTarget,
20 layer as toLayer,
21 makeAstroInlineConfig,
22 NODE_ENVIRONMENTS,
23 type AstroTarget,
24} from "../index.ts";
25
26const ROOT = "/project";
27
28const TEST_ADAPTER: AstroIntegration = { name: "test-adapter", hooks: {} };
29
30const flatten = (plugins: unknown): Array<ViteModule.Plugin> =>
31 ((plugins ?? []) as Array<unknown>)
32 .flat(Infinity)
33 .filter(
34 (plugin): plugin is ViteModule.Plugin =>
35 typeof plugin === "object" && plugin !== null && "name" in plugin,
36 );
37
38type CapturedConfig = {
39 build?: { redirects?: boolean };
40 vite?: { plugins?: unknown };
41 image?: {
42 service?: { entrypoint?: string | URL };
43 endpoint?: { entrypoint?: string | URL };
44 };
45};
46
47const noopLogger = {
48 info: () => {},
49 warn: () => {},
50 error: () => {},
51 debug: () => {},
52};
53
54const runConfigSetup = (
55 integration: AstroIntegration,
56 command: "dev" | "build" | "sync",
57): CapturedConfig => {
58 let captured: CapturedConfig | undefined;
59 const hook = integration.hooks["astro:config:setup"];
60 if (!hook) throw new Error("astro:config:setup hook missing");
61 void hook({
62 command,
63 config: { vite: {}, image: {} },
64 updateConfig: (config: unknown) => {
65 captured = config as CapturedConfig;
66 return {} as never;
67 },
68 logger: noopLogger,
69 } as never);
70 if (!captured) throw new Error("updateConfig was not called");
71 return captured;
72};
73
74const objectHook = <A extends Array<unknown>, R>(
75 hook: unknown,
76): ((this: unknown, ...args: A) => R) => {
77 if (typeof hook === "function") return hook as never;
78 if (typeof hook === "object" && hook !== null && "handler" in hook) {
79 return (hook as { handler: (this: unknown, ...args: A) => R }).handler;
80 }
81 throw new Error("hook is neither a function nor an object hook");
82};
83
84describe("cloudflare target module", () => {
85 it("default-exports a factory producing an AstroTarget for the cloudflare platform", () => {
86 const target = cloudflareTarget({});
87 expect(target.platform).toBe("cloudflare");
88 expect(isAstroTarget(target)).toBe(true);
89 expect(target.bundle?.conditions).toEqual([
90 "workerd",
91 "worker",
92 "module",
93 "browser",
94 ]);
95 expect(target.bundle?.external).toEqual(["cloudflare:"]);
96 expect(target.build).toBeUndefined();
97 expect(target.serve).toBeUndefined();
98 });
99
100 it("carries the config opaquely and builds the forked integration from it", () => {
101 const worker = { compatibilityDate: "2026-03-10" };
102 const target = cloudflareTarget({
103 worker,
104 prerenderEnvironment: "node",
105 sessionKVBindingName: "MY_SESSION",
106 });
107 expect(target.config).toEqual({
108 worker,
109 prerenderEnvironment: "node",
110 sessionKVBindingName: "MY_SESSION",
111 });
112 const integration = target.integration();
113 expect(integration.name).toBe("@alchemy.run/frontend-frameworks/astro");
114 expect(integration.hooks["astro:config:setup"]).toBeDefined();
115 });
116
117 it("is the default target specifier of the framework module", () => {
118 expect(DEFAULT_TARGET_SPECIFIER).toBe(
119 "@alchemy.run/frontend-frameworks/astro/cloudflare",
120 );
121 });
122});
123
124describe("isAstroTarget", () => {
125 it("accepts a DeployTarget with an integration hook and rejects others", () => {
126 expect(isAstroTarget(cloudflareTarget())).toBe(true);
127 expect(isAstroTarget({ platform: "cloudflare", config: {} })).toBe(false);
128 expect(isAstroTarget(undefined)).toBe(false);
129 expect(isAstroTarget("cloudflare")).toBe(false);
130 });
131});
132
133describe("makeIntegrationPluginOptions", () => {
134 it("pins main, the ssr entry environment, and the node skipEnvironments", () => {
135 const options = makeIntegrationPluginOptions();
136 expect(options.main).toBe(SERVER_ENTRYPOINT);
137 expect(options.viteEnvironments).toEqual({ entry: "ssr" });
138 expect(options.skipEnvironments).toEqual([...NODE_ENVIRONMENTS]);
139 });
140
141 it("preserves user cloudflare options but overrides the structural ones", () => {
142 const options = makeIntegrationPluginOptions({
143 compatibilityDate: "2026-03-10",
144 compatibilityFlags: ["nodejs_compat"],
145 worker: { name: "fixtures-astro", bindings: [] },
146 main: "/somewhere/else.ts",
147 viteEnvironments: { entry: "rsc" },
148 skipEnvironments: ["custom", "prerender"],
149 });
150 expect(options.compatibilityDate).toBe("2026-03-10");
151 expect(options.compatibilityFlags).toEqual(["nodejs_compat"]);
152 expect(options.worker?.name).toBe("fixtures-astro");
153 // Astro-structural options always win: the worker entry is the vendored
154 // server entrypoint and Astro pins the worker environment name to "ssr".
155 expect(options.main).toBe(SERVER_ENTRYPOINT);
156 expect(options.viteEnvironments).toEqual({ entry: "ssr" });
157 expect(options.skipEnvironments).toEqual(["astro", "prerender", "custom"]);
158 });
159});
160
161describe("makeAstroInlineConfig", () => {
162 it("pins root, leaves configFile undiscovered-default, injects the target integration; leaves output unset", () => {
163 const config = makeAstroInlineConfig({
164 root: ROOT,
165 integration: TEST_ADAPTER,
166 });
167 expect(config.root).toBe(ROOT);
168 // The user's astro.config.* must load natively: configFile stays
169 // undefined so astro's own discovery runs (and degrades gracefully to
170 // no file — the internal programmatic fallback).
171 expect(config.configFile).toBeUndefined();
172 // The inline config merges OVER the config file, so a default `output`
173 // here would clobber the file's (e.g. an explicit "static"). It must
174 // only appear when passed explicitly via the user overrides.
175 expect("output" in config).toBe(false);
176 // The target integration rides in `integrations` (it self-registers as
177 // the adapter at astro:config:done), never in `adapter` — an inline
178 // `adapter` would deep-merge with a user-file adapter object.
179 expect(config.adapter).toBeUndefined();
180 expect(
181 (config.integrations as Array<AstroIntegration>).map((i) => i.name),
182 ).toEqual(["test-adapter"]);
183 });
184
185 it("merges user config, appends the target integration after the user's", () => {
186 const userIntegration: AstroIntegration = {
187 name: "user-integration",
188 hooks: {},
189 };
190 const config = makeAstroInlineConfig({
191 root: ROOT,
192 integration: TEST_ADAPTER,
193 userConfig: {
194 site: "https://example.com",
195 output: "static",
196 root: "/elsewhere",
197 integrations: [userIntegration],
198 devToolbar: { enabled: false },
199 },
200 });
201 expect(config.site).toBe("https://example.com");
202 expect(config.output).toBe("static");
203 expect(config.devToolbar).toEqual({ enabled: false });
204 expect(config.root).toBe(ROOT);
205 expect(config.configFile).toBeUndefined();
206 expect(
207 (config.integrations as Array<AstroIntegration>).map((i) => i.name),
208 ).toEqual(["user-integration", "test-adapter"]);
209 });
210
211 it("lets a user-supplied adapter flow through so astro:config:done rejects it with the actionable error", () => {
212 const config = makeAstroInlineConfig({
213 root: ROOT,
214 integration: TEST_ADAPTER,
215 userConfig: { adapter: { name: "user-adapter", hooks: {} } },
216 });
217 expect((config.adapter as AstroIntegration).name).toBe("user-adapter");
218 });
219
220 it("merges the dev port into server options", () => {
221 const config = makeAstroInlineConfig({
222 root: ROOT,
223 integration: TEST_ADAPTER,
224 userConfig: { server: { host: "127.0.0.1" } },
225 port: 3102,
226 });
227 expect(config.server).toEqual({ host: "127.0.0.1", port: 3102 });
228 });
229
230 it("appends extra vite plugins after the user's", () => {
231 const userPlugin: ViteModule.Plugin = { name: "user-plugin" };
232 const collectorPlugin: ViteModule.Plugin = { name: "alchemy:build-output" };
233 const config = makeAstroInlineConfig({
234 root: ROOT,
235 integration: TEST_ADAPTER,
236 userConfig: { vite: { plugins: [userPlugin] } },
237 extraVitePlugins: [collectorPlugin],
238 });
239 const plugins = flatten(config.vite?.plugins);
240 const names = plugins.map((plugin) => plugin.name);
241 expect(names.indexOf("user-plugin")).toBeLessThan(
242 names.indexOf("alchemy:build-output"),
243 );
244 });
245});
246
247describe("distilledCloudflare astro:config:setup", () => {
248 it("injects the cloudflare plugins, config plugin, and prerender plugins in dev", () => {
249 // Default (workerd) prerendering: no node dev middleware, the workerd
250 // prerender environment plugin instead.
251 const workerdNames = flatten(
252 runConfigSetup(distilledCloudflare(), "dev").vite?.plugins,
253 ).map((plugin) => plugin.name);
254 expect(workerdNames).toContain(
255 "@alchemy.run/frontend-frameworks/astro:workerd-prerender-environment",
256 );
257 expect(workerdNames).not.toContain(
258 "@alchemy.run/frontend-frameworks/astro:dev-server-prerender-middleware",
259 );
260
261 const captured = runConfigSetup(
262 distilledCloudflare({ prerenderEnvironment: "node" }),
263 "dev",
264 );
265 const plugins = flatten(captured.vite?.plugins);
266 const names = plugins.map((plugin) => plugin.name);
267 expect(names).toContain(
268 "@alchemy.run/frontend-frameworks/astro:dev-server-prerender-middleware",
269 );
270 expect(names).toContain(
271 "@alchemy.run/frontend-frameworks/astro:cf-imports",
272 );
273 expect(names).toContain(
274 "@alchemy.run/frontend-frameworks/astro:environment",
275 );
276 expect(names).toContain(
277 "@alchemy.run/frontend-frameworks/astro:cf-externals",
278 );
279 expect(names).toContain("virtual:astro-cloudflare:config");
280 const cloudflare = plugins.filter((plugin) =>
281 plugin.name.startsWith("distilled-cloudflare"),
282 );
283 expect(cloudflare.length).toBeGreaterThan(0);
284 // Dev keeps the dev server hooks intact.
285 expect(
286 cloudflare.some((plugin) => plugin.configureServer !== undefined),
287 ).toBe(true);
288 expect(captured.build?.redirects).toBe(false);
289 });
290
291 it("strips configureServer from the cloudflare plugins during build/sync typegen", () => {
292 for (const command of ["build", "sync"] as const) {
293 const captured = runConfigSetup(distilledCloudflare(), command);
294 const plugins = flatten(captured.vite?.plugins);
295 const names = plugins.map((plugin) => plugin.name);
296 expect(names).not.toContain(
297 "@alchemy.run/frontend-frameworks/astro:dev-server-prerender-middleware",
298 );
299 const cloudflare = plugins.filter((plugin) =>
300 plugin.name.startsWith("distilled-cloudflare"),
301 );
302 expect(cloudflare.length).toBeGreaterThan(0);
303 expect(
304 cloudflare.every((plugin) => plugin.configureServer === undefined),
305 ).toBe(true);
306 }
307 });
308
309 it("configures the passthrough image service with the phase-specific endpoint", () => {
310 const dev = runConfigSetup(distilledCloudflare(), "dev");
311 expect(dev.image?.service?.entrypoint).toBeDefined();
312 expect(dev.image?.endpoint).toEqual({
313 entrypoint: "astro/assets/endpoint/generic",
314 });
315 const build = runConfigSetup(distilledCloudflare(), "build");
316 expect(build.image?.endpoint).toEqual({
317 entrypoint: IMAGE_PASSTHROUGH_ENDPOINT,
318 });
319 });
320
321 it("pre-bundles the server environments in dev but disables discovery during typegen", () => {
322 const findEnvironmentPlugin = (command: "dev" | "build") => {
323 const captured = runConfigSetup(distilledCloudflare(), command);
324 const plugin = flatten(captured.vite?.plugins).find(
325 (candidate) =>
326 candidate.name ===
327 "@alchemy.run/frontend-frameworks/astro:environment",
328 );
329 if (!plugin) throw new Error("environment plugin missing");
330 return objectHook<
331 [string, ViteModule.EnvironmentOptions],
332 { optimizeDeps?: ViteModule.DepOptimizationOptions } | undefined
333 >(plugin.configEnvironment);
334 };
335
336 const dev = findEnvironmentPlugin("dev");
337 expect(dev.call({}, "ssr", {})?.optimizeDeps?.include).toContain(
338 SERVER_ENTRYPOINT,
339 );
340 expect(dev.call({}, "client", {})?.optimizeDeps?.include).toContain(
341 "astro/runtime/client/dev-toolbar/entrypoint.js",
342 );
343 expect(dev.call({}, "custom", {})).toBeUndefined();
344 // Environments already configured with an explicit (no-discovery)
345 // optimizer are left alone.
346 expect(
347 dev.call({}, "ssr", { optimizeDeps: { noDiscovery: true } }),
348 ).toBeUndefined();
349
350 const typegen = findEnvironmentPlugin("build");
351 expect(typegen.call({}, "ssr", {})?.optimizeDeps).toEqual({
352 noDiscovery: true,
353 include: [],
354 });
355 });
356
357 it("scopes cf-externals to the worker-resolved server environments", () => {
358 const captured = runConfigSetup(distilledCloudflare(), "build");
359 const plugin = flatten(captured.vite?.plugins).find(
360 (candidate) =>
361 candidate.name ===
362 "@alchemy.run/frontend-frameworks/astro:cf-externals",
363 );
364 if (!plugin) throw new Error("cf-externals plugin missing");
365 const applies = plugin.applyToEnvironment as (environment: {
366 name: string;
367 }) => boolean;
368 expect(applies({ name: "ssr" })).toBe(true);
369 expect(applies({ name: "prerender" })).toBe(true);
370 expect(applies({ name: "client" })).toBe(false);
371 const config = objectHook<[{ ssr?: { external?: unknown } }], void>(
372 plugin.config,
373 );
374 const conf = { ssr: { external: ["some-dep"] } };
375 config.call({}, conf);
376 expect(conf.ssr.external).toBeUndefined();
377 });
378});
379
380describe("distilledCloudflare astro:config:done", () => {
381 it("registers the adapter without a preview entrypoint", () => {
382 let adapter: Record<string, unknown> | undefined;
383 const integration = distilledCloudflare();
384 const hook = integration.hooks["astro:config:done"];
385 if (!hook) throw new Error("astro:config:done hook missing");
386 void hook({
387 buildOutput: "server",
388 config: {
389 base: "/",
390 build: { client: new URL("file:///project/dist/client/") },
391 },
392 injectTypes: () => new URL("file:///dev/null"),
393 setAdapter: (value: unknown) => {
394 adapter = value as Record<string, unknown>;
395 },
396 } as never);
397 expect(adapter?.name).toBe("@alchemy.run/frontend-frameworks/astro");
398 expect(adapter?.previewEntrypoint).toBeUndefined();
399 expect(adapter?.serverEntrypoint).toBeUndefined();
400 expect(adapter?.adapterFeatures).toMatchObject({
401 buildOutput: "server",
402 middlewareMode: "classic",
403 preserveBuildClientDir: true,
404 preserveBuildServerDir: true,
405 });
406 });
407
408 it("injects a hookless adapter marker at astro:config:setup when the config declares none", () => {
409 // Astro's build refuses server output unless `config.adapter` is set;
410 // the integration (injected via `integrations`) satisfies the check
411 // with an inert marker while `setAdapter` registers the real adapter.
412 const integration = distilledCloudflare();
413 const updates: Array<Record<string, unknown>> = [];
414 const hook = integration.hooks["astro:config:setup"];
415 if (!hook) throw new Error("astro:config:setup hook missing");
416 void hook({
417 command: "build",
418 config: { vite: {}, image: {} },
419 updateConfig: (update: unknown) => {
420 updates.push(update as Record<string, unknown>);
421 return {} as never;
422 },
423 logger: noopLogger,
424 } as never);
425 const marker = updates.find((update) => update.adapter !== undefined)
426 ?.adapter as { name: string; hooks: Record<string, unknown> } | undefined;
427 expect(marker).toEqual({
428 name: "@alchemy.run/frontend-frameworks/astro",
429 hooks: {},
430 });
431 // With the marker injected, astro:config:done registers the adapter.
432 const done = integration.hooks["astro:config:done"];
433 if (!done) throw new Error("astro:config:done hook missing");
434 let adapter: Record<string, unknown> | undefined;
435 void done({
436 buildOutput: "server",
437 config: {
438 base: "/",
439 adapter: marker,
440 build: { client: new URL("file:///project/dist/client/") },
441 },
442 injectTypes: () => new URL("file:///dev/null"),
443 setAdapter: (value: unknown) => {
444 adapter = value as Record<string, unknown>;
445 },
446 } as never);
447 expect(adapter?.name).toBe("@alchemy.run/frontend-frameworks/astro");
448 });
449
450 it("does not inject the marker when the config declares an adapter, and fails at astro:config:done", () => {
451 const integration = distilledCloudflare();
452 const updates: Array<Record<string, unknown>> = [];
453 const setup = integration.hooks["astro:config:setup"];
454 if (!setup) throw new Error("astro:config:setup hook missing");
455 void setup({
456 command: "build",
457 config: {
458 vite: {},
459 image: {},
460 adapter: { name: "@astrojs/cloudflare", hooks: {} },
461 },
462 updateConfig: (update: unknown) => {
463 updates.push(update as Record<string, unknown>);
464 return {} as never;
465 },
466 logger: noopLogger,
467 } as never);
468 expect(updates.every((update) => update.adapter === undefined)).toBe(true);
469 const done = integration.hooks["astro:config:done"];
470 if (!done) throw new Error("astro:config:done hook missing");
471 expect(() =>
472 done({
473 buildOutput: "server",
474 config: {
475 base: "/",
476 adapter: { name: "@astrojs/cloudflare", hooks: {} },
477 build: { client: new URL("file:///project/dist/client/") },
478 },
479 injectTypes: () => new URL("file:///dev/null"),
480 setAdapter: () => {},
481 } as never),
482 ).toThrow(
483 /declares the adapter "@astrojs\/cloudflare"[\s\S]*Remove `adapter`/,
484 );
485 });
486});
487
488describe("distilledCloudflare astro:build:setup", () => {
489 it("applies the server-target vite tweaks", () => {
490 const integration = distilledCloudflare();
491 const hook = integration.hooks["astro:build:setup"];
492 if (!hook) throw new Error("astro:build:setup hook missing");
493 const viteConfig: Record<string, any> = {};
494 void hook({ vite: viteConfig, target: "server" } as never);
495 expect(viteConfig.ssr?.noExternal).toBe(true);
496 expect(viteConfig.build?.rolldownOptions?.external).toEqual(["sharp"]);
497 expect(viteConfig.build?.rolldownOptions?.output?.banner).toContain(
498 "globalThis.process",
499 );
500 expect(viteConfig.define?.["globalThis.__ASTRO_IMAGES_BINDING_NAME"]).toBe(
501 '"IMAGES"',
502 );
503
504 const clientConfig: Record<string, any> = {};
505 void hook({ vite: clientConfig, target: "client" } as never);
506 expect(clientConfig.ssr).toBeUndefined();
507 });
508});
509
510describe("config plugin (virtual:astro-cloudflare:config)", () => {
511 const plugin = createConfigPlugin({
512 sessionKVBindingName: "SESSION",
513 compileImageConfig: null,
514 cacheProviderEnabled: false,
515 });
516
517 it("resolves and loads the virtual module", () => {
518 const resolveId = objectHook<[string], string>(plugin.resolveId);
519 const resolved = resolveId.call({}, "virtual:astro-cloudflare:config");
520 expect(resolved).toBe("\0virtual:astro-cloudflare:config");
521
522 const load = objectHook<[string], string>(plugin.load);
523 const code = load.call({ environment: { name: "ssr" } }, resolved);
524 expect(code).toContain('export const sessionKVBindingName = "SESSION"');
525 expect(code).toContain("export const compileImageConfig = null");
526 expect(code).toContain("export const cacheProviderEnabled = false");
527 expect(code).toContain("export const isPrerender = false");
528 });
529
530 it("marks isPrerender only in the prerender environment", () => {
531 const load = objectHook<[string], string>(plugin.load);
532 const code = load.call(
533 { environment: { name: "prerender" } },
534 "\0virtual:astro-cloudflare:config",
535 );
536 expect(code).toContain("export const isPrerender = true");
537 });
538});
539
540describe("vendored runtime purity", () => {
541 it("never imports wrangler, @astrojs/cloudflare, or @cloudflare/vite-plugin", async () => {
542 const runtimeDir = NodePath.resolve(import.meta.dirname, "../runtime");
543 const entries = await NodeFsPromises.readdir(runtimeDir, {
544 recursive: true,
545 withFileTypes: true,
546 });
547 const files = entries
548 .filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
549 .map((entry) => NodePath.join(entry.parentPath, entry.name));
550 expect(files.length).toBeGreaterThan(0);
551 for (const file of files) {
552 const source = await NodeFsPromises.readFile(file, "utf8");
553 expect(source, file).not.toMatch(/["']wrangler["']|["']wrangler\//);
554 expect(source, file).not.toMatch(/["']@astrojs\/cloudflare/);
555 expect(source, file).not.toMatch(/["']@cloudflare\/vite-plugin/);
556 }
557 });
558});
559
560describe("framework factory", () => {
561 it("default-exports a factory producing a Layer<Framework>", () => {
562 expect(Layer.isLayer(framework({}))).toBe(true);
563 expect(Layer.isLayer(toLayer())).toBe(true);
564 });
565
566 it("accepts the harness's target-scoped carriage and the deprecated vite alias", () => {
567 expect(
568 Layer.isLayer(
569 framework({
570 target: {
571 cloudflare: { worker: { compatibilityDate: "2026-03-10" } },
572 },
573 }),
574 ),
575 ).toBe(true);
576 expect(
577 Layer.isLayer(framework({ vite: { compatibilityDate: "2026-03-10" } })),
578 ).toBe(true);
579 });
580});
581
582describe("deploy-target resolution", () => {
583 const OUTPUT: BuildOutput = {
584 clientDirectory: undefined,
585 serverModules: [
586 { name: "entry.mjs", content: "export default {}", hash: "hash" },
587 ],
588 externalWorkspaces: new Set<string>(),
589 };
590
591 const run = <A, E>(
592 layer: Layer.Layer<Framework, unknown, never>,
593 effect: Effect.Effect<A, E, Framework>,
594 ) =>
595 Effect.runPromise(
596 effect.pipe(Effect.provide(layer)) as Effect.Effect<A, E, never>,
597 );
598
599 const build = Effect.flatMap(Framework, (service) => service.build());
600
601 it("delegates the build wholesale when the target defines `build`", async () => {
602 const contexts: Array<{ root: string; framework?: string | undefined }> =
603 [];
604 const target: AstroTarget = {
605 platform: "test",
606 config: {},
607 integration: () => TEST_ADAPTER,
608 build: (context) => {
609 contexts.push({ root: context.root, framework: context.framework });
610 return Effect.succeed(OUTPUT);
611 },
612 };
613 const layer = toLayer({ target }).pipe(Layer.provide(NodeServices.layer));
614 const output = await run(layer, build);
615 expect(output).toEqual(OUTPUT);
616 expect(contexts).toHaveLength(1);
617 expect(contexts[0]?.framework).toBe("astro");
618 expect(NodePath.isAbsolute(contexts[0]?.root ?? "")).toBe(true);
619 });
620
621 it("fails with a FrameworkError when the resolved target is not an AstroTarget", async () => {
622 const layer = toLayer({
623 target: { platform: "test", config: {} } as unknown as AstroTarget,
624 }).pipe(Layer.provide(NodeServices.layer));
625 const error = await run(layer, Effect.flip(build));
626 expect(error).toMatchObject({
627 _tag: "FrameworkError",
628 framework: "astro",
629 });
630 expect(String((error as { message: string }).message)).toContain(
631 "AstroTarget",
632 );
633 });
634
635 it("applies a target factory to targetConfig", async () => {
636 const configs: Array<unknown> = [];
637 const layer = toLayer({
638 target: (config: { flag: boolean }) => {
639 configs.push(config);
640 return {
641 platform: "test",
642 config,
643 integration: () => TEST_ADAPTER,
644 build: () => Effect.succeed(OUTPUT),
645 } satisfies AstroTarget<{ flag: boolean }>;
646 },
647 targetConfig: { flag: true },
648 }).pipe(Layer.provide(NodeServices.layer));
649 const output = await run(layer, build);
650 expect(output).toEqual(OUTPUT);
651 expect(configs).toEqual([{ flag: true }]);
652 });
653});