1import type { CloudflareVitePluginOptions } from "@alchemy.run/cloudflare-runtime/vite";
2import type { Framework } from "@alchemy.run/frontend-frameworks/core";
3import type * as Miniflare from "../miniflare/miniflare.ts";
4import * as Effect from "effect/Effect";
5import type * as FileSystem from "effect/FileSystem";
6import type * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Predicate from "effect/Predicate";
9import { Cwd } from "./Cwd.ts";
10
11const kOptions = Symbol("@alchemy.run/cloudflare-test-tools/e2e/Options");
12
13/**
14 * Cloudflare-target configuration — everything the cloudflare deploy target
15 * needs to build, dev-serve, and preview a fixture.
16 */
17export interface CloudflareTargetOptions {
18 /**
19 * Cloudflare worker configuration (compatibility date/flags, worker name,
20 * bindings, assets) consumed by the built-in Vite implementation of the
21 * `Framework` service — and, by convention, by framework packages named via
22 * {@link Options.framework} (they read the same shape for their cloudflare
23 * plugin options). Optional so assets-only fixtures can omit it.
24 */
25 readonly worker?: CloudflareVitePluginOptions;
26 /**
27 * Miniflare options for the preview server (`e2e preview` / the Playwright
28 * `live` mode) — the cloudflare target's serving story for built output.
29 */
30 readonly preview?: Options.MiniflareOptions;
31}
32
33/**
34 * Target selection + target-scoped config carriage: one optional key per
35 * platform, opaque to everything except that platform's target. Cloudflare is
36 * the only implemented target today; a future AWS target adds its own key
37 * (e.g. `aws?: AwsTargetOptions`) without touching the framework plumbing.
38 */
39export interface TargetOptions {
40 /**
41 * Which platform target drives this fixture.
42 * @default "cloudflare"
43 */
44 readonly name?: "cloudflare";
45 readonly cloudflare?: CloudflareTargetOptions;
46}
47
48export interface Options {
49 /**
50 * The framework's project root, relative to the fixture root (the harness
51 * cwd). Threaded by the harness into `Framework.build`/`Framework.dev`
52 * (`FrameworkBuildOptions.root` / `FrameworkDevOptions.root`) so a fixture
53 * whose app lives in a subdirectory (e.g. a monorepo-shaped fixture with the
54 * Vite project at `app/`) needs no framework-Layer wrapping. The harness's
55 * own persistence (`dist/build.json`) stays anchored at the fixture root.
56 * @default the fixture root
57 */
58 readonly root?: string;
59 /**
60 * Deploy-target selection and target-scoped configuration. Defaults to the
61 * cloudflare target. Frameworks and the harness read the resolved shape via
62 * {@link resolveCloudflareOptions}, which also honors the deprecated
63 * top-level `vite`/`miniflare` aliases.
64 */
65 readonly target?: TargetOptions;
66 /**
67 * @deprecated Alias for `target.cloudflare.worker` — kept so existing
68 * `e2e.config.ts` files work unchanged. Prefer the target-scoped form.
69 */
70 readonly vite?: CloudflareVitePluginOptions;
71 /**
72 * @deprecated Alias for `target.cloudflare.preview` — kept so existing
73 * `e2e.config.ts` files work unchanged. Prefer the target-scoped form.
74 */
75 readonly miniflare?: Options.MiniflareOptions;
76 /**
77 * The framework integration implementing `dev`/`build` for this fixture.
78 *
79 * - omitted — the built-in Vite implementation (default; zero behavior
80 * change for existing fixtures)
81 * - a package specifier (e.g. `"@alchemy.run/frontend-frameworks/waku"`) — loaded from the
82 * *fixture's* own `node_modules`; the module must default-export (or
83 * named-export `framework`) a factory `(options: Options) =>
84 * Layer<Framework>` (a `Layer<Framework>` export is also accepted)
85 * - a factory function — called with these options
86 * - a `Layer<Framework>` — used as-is (build it yourself in `e2e.config.ts`
87 * by importing the framework package directly, for full type safety over
88 * framework-specific options)
89 */
90 readonly framework?: Options.FrameworkInput;
91}
92
93/**
94 * The resolved cloudflare-target configuration: the target-scoped form merged
95 * with the deprecated top-level aliases (target-scoped wins). This is the
96 * single accessor the harness — and, by convention, framework packages that
97 * read the harness options structurally — use to obtain cloudflare config;
98 * nothing else should touch `options.vite` / `options.miniflare` directly.
99 */
100export const resolveCloudflareOptions = (
101 options: Options,
102): {
103 worker: CloudflareVitePluginOptions | undefined;
104 preview: Options.MiniflareOptions;
105} => ({
106 worker: options.target?.cloudflare?.worker ?? options.vite,
107 preview: options.target?.cloudflare?.preview ?? options.miniflare ?? {},
108});
109
110/**
111 * Resolve {@link Options.root} to an absolute project root (against the
112 * harness cwd), or `undefined` when the fixture root is the project root —
113 * the value the harness passes to `Framework.build`/`Framework.dev` as
114 * `FrameworkBuildOptions.root` / `FrameworkDevOptions.root`.
115 */
116export const resolveRoot: (
117 options: Options,
118) => Effect.Effect<string | undefined, never, Path.Path> = Effect.fn(function* (
119 options: Options,
120) {
121 if (options.root === undefined) {
122 return undefined;
123 }
124 const path = yield* Path.Path;
125 const cwd = yield* Cwd;
126 return path.resolve(cwd, options.root);
127});
128
129export declare namespace Options {
130 type Input = Options | Effect.Effect<Options>;
131 type MiniflareOptions = {
132 // `NonNullable` before `Omit`: newer miniflare versions surface the
133 // optional `assets` slot as `{...} | undefined`, and `Omit` over a
134 // union with `undefined` collapses to `{}`.
135 [K in keyof Miniflare.Options]?: K extends "assets"
136 ? Omit<NonNullable<Miniflare.Options[K]>, "directory">
137 : Miniflare.Options[K];
138 };
139
140 /**
141 * Services the harness runtime provides to a framework Layer while it is
142 * being built (the package's Node platform services, plus a dotenv/env
143 * `ConfigProvider`).
144 */
145 type FrameworkServices = FileSystem.FileSystem | Path.Path;
146
147 /**
148 * What a framework integration ultimately provides: a Layer for
149 * framework-core's `Framework` service.
150 */
151 type FrameworkLayer = Layer.Layer<Framework, unknown, FrameworkServices>;
152
153 /**
154 * A factory producing the {@link FrameworkLayer} from the fixture's
155 * options. May return the Layer directly or an Effect of it.
156 */
157 type FrameworkFactory = (
158 options: Options,
159 ) =>
160 | FrameworkLayer
161 | Effect.Effect<FrameworkLayer, unknown, FrameworkServices>;
162
163 type FrameworkInput = string | FrameworkLayer | FrameworkFactory;
164}
165
166export const make = (options: Options.Input) =>
167 Object.assign(options, { [kOptions]: true });
168
169export const load = Effect.fn(function* () {
170 const path = yield* Path.Path;
171 const cwd = yield* Cwd;
172 const url = yield* path
173 .toFileUrl(path.resolve(cwd, "e2e.config.ts"))
174 .pipe(Effect.map((url) => url.href));
175 const options = yield* Effect.promise(
176 async () => await import(url).then((m) => m.default as Options.Input),
177 );
178 if (Effect.isEffect(options)) {
179 return yield* options;
180 }
181 if (!Predicate.hasProperty(options, kOptions)) {
182 throw new Error("No e2e.config.ts found");
183 }
184 return options;
185});