| 1 | import type { CloudflareVitePluginOptions } from "@alchemy.run/cloudflare-runtime/vite"; |
| 2 | import type { Framework } from "@alchemy.run/frontend-frameworks/core"; |
| 3 | import type * as Miniflare from "../miniflare/miniflare.ts"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import type * as FileSystem from "effect/FileSystem"; |
| 6 | import type * as Layer from "effect/Layer"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as Predicate from "effect/Predicate"; |
| 9 | import { Cwd } from "./Cwd.ts"; |
| 10 | |
| 11 | const 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 | */ |
| 17 | export 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 | */ |
| 39 | export interface TargetOptions { |
| 40 | /** |
| 41 | * Which platform target drives this fixture. |
| 42 | * @default "cloudflare" |
| 43 | */ |
| 44 | readonly name?: "cloudflare"; |
| 45 | readonly cloudflare?: CloudflareTargetOptions; |
| 46 | } |
| 47 | |
| 48 | export 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 | */ |
| 100 | export 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 | */ |
| 116 | export 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 | |
| 129 | export 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 | |
| 166 | export const make = (options: Options.Input) => |
| 167 | Object.assign(options, { [kOptions]: true }); |
| 168 | |
| 169 | export 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 | }); |