| 1 | # @alchemy.run/cloudflare-test-tools/e2e |
| 2 | |
| 3 | The e2e harness that drives a fixture's dev server, production build, and |
| 4 | miniflare preview, and exposes them to Playwright. The harness is |
| 5 | framework-pluggable: every fixture is driven through framework-core's |
| 6 | `Framework` service, and a fixture selects its implementation in |
| 7 | `e2e.config.ts`. When no framework is named, the built-in Vite implementation |
| 8 | (over `@alchemy.run/cloudflare-runtime/vite`) is used — existing Vite |
| 9 | fixtures need no changes. |
| 10 | |
| 11 | ## How a fixture uses the harness |
| 12 | |
| 13 | A fixture's `package.json` scripts call the `e2e` bin: |
| 14 | |
| 15 | ```jsonc |
| 16 | { |
| 17 | "scripts": { |
| 18 | "dev": "e2e dev", // Framework.dev — the framework's dev server |
| 19 | "build": "e2e build", // Framework.build; the harness persists dist/build.json |
| 20 | "preview": "e2e preview", // miniflare over dist/build.json |
| 21 | "test": "playwright test", |
| 22 | }, |
| 23 | } |
| 24 | ``` |
| 25 | |
| 26 | `e2e.config.ts` at the fixture root default-exports `Options.make({...})` (or |
| 27 | an `Effect` of it, for config that reads env via `Config`): |
| 28 | |
| 29 | ```ts |
| 30 | import * as Options from "@alchemy.run/cloudflare-test-tools/e2e/Options"; |
| 31 | |
| 32 | export default Options.make({ |
| 33 | framework: "@alchemy.run/frontend-frameworks/waku", // ← optional; omit for the Vite default |
| 34 | target: { |
| 35 | // name: "cloudflare" is the default (the only implemented target today) |
| 36 | cloudflare: { |
| 37 | worker: { |
| 38 | compatibilityDate: "2026-03-10", |
| 39 | compatibilityFlags: ["nodejs_compat"], |
| 40 | worker: { name: "fixtures-waku", bindings: [], assets: {} }, |
| 41 | }, |
| 42 | preview: { |
| 43 | compatibilityDate: "2026-03-10", |
| 44 | compatibilityFlags: ["nodejs_compat"], |
| 45 | assets: { |
| 46 | /* router/asset config */ |
| 47 | }, |
| 48 | }, |
| 49 | }, |
| 50 | }, |
| 51 | }); |
| 52 | ``` |
| 53 | |
| 54 | Config is **target-scoped**: `target.<platform>` carries everything that |
| 55 | platform's deploy target needs, opaque to the rest of the harness (a future |
| 56 | AWS target adds `target.aws` without touching the plumbing). The pre-target |
| 57 | top-level fields remain as deprecated aliases — `vite` ≙ |
| 58 | `target.cloudflare.worker`, `miniflare` ≙ `target.cloudflare.preview` — and |
| 59 | `Options.resolveCloudflareOptions(options)` is the single accessor that |
| 60 | merges them (target-scoped wins). Existing fixture configs work unchanged. |
| 61 | |
| 62 | Playwright fixtures come from `@alchemy.run/cloudflare-test-tools/e2e/Playwright`: |
| 63 | `Playwright.make("live")` boots the miniflare preview (building first if |
| 64 | `dist/build.json` is absent), `Playwright.make("dev")` boots the framework dev |
| 65 | server. Both dispatch through the `Framework` service. |
| 66 | |
| 67 | ## The `framework` option |
| 68 | |
| 69 | `Options.framework` selects the `Framework`-service implementation. Accepted |
| 70 | forms: |
| 71 | |
| 72 | 1. **Omitted** — the built-in Vite implementation, configured by |
| 73 | `options.vite`. Zero behavior change for existing fixtures. |
| 74 | 2. **A package specifier string** (e.g. `"@alchemy.run/frontend-frameworks/waku"`) — resolved |
| 75 | and imported from the **fixture's own `node_modules`** via framework-core's |
| 76 | `loadProjectModule(cwd, specifier)`, so the fixture's installed copy of the |
| 77 | framework (its `waku`, `astro`, `next`, ... dependency tree) is the one |
| 78 | driven — never whatever is hoisted next to the harness. Relative specifiers |
| 79 | that the fixture's resolution can reach work too. |
| 80 | 3. **A factory function** — called with the fixture's full `Options`. |
| 81 | 4. **A `Layer<Framework>`** — used as-is. Build it yourself in `e2e.config.ts` |
| 82 | by importing the framework package directly; this is the fully-typed path |
| 83 | for framework-specific options that don't fit the shared `Options` shape. |
| 84 | |
| 85 | ## What a framework package must export |
| 86 | |
| 87 | A package named by the string form must **default-export** (or named-export |
| 88 | `framework`) one of: |
| 89 | |
| 90 | - a factory `(options: Options) => Layer<Framework>` — the primary contract; |
| 91 | - a factory returning `Effect<Layer<Framework>>` (for effectful setup); |
| 92 | - a `Layer<Framework>` directly (for packages with no per-fixture options). |
| 93 | |
| 94 | ```ts |
| 95 | // @alchemy.run/frontend-frameworks/waku — src/waku/index.ts |
| 96 | import type { Options } from "@alchemy.run/cloudflare-test-tools/e2e/Options"; |
| 97 | import { Framework } from "@alchemy.run/frontend-frameworks/core"; |
| 98 | import * as Layer from "effect/Layer"; |
| 99 | |
| 100 | export default (options: Options): Layer.Layer<Framework> => |
| 101 | Layer.effect(Framework /* ... build/dev over waku ... */); |
| 102 | ``` |
| 103 | |
| 104 | ### Options flow |
| 105 | |
| 106 | The factory receives the **entire** parsed `Options` object from the fixture's |
| 107 | `e2e.config.ts`. Conventions: |
| 108 | |
| 109 | - `options.target.cloudflare.worker` (`CloudflareVitePluginOptions`) carries |
| 110 | the cloudflare worker configuration — compatibility date/flags, worker |
| 111 | name, bindings, assets behavior. Framework packages should read |
| 112 | `options.target?.cloudflare?.worker ?? options.vite` (the deprecated |
| 113 | top-level alias) so fixtures stay uniform. |
| 114 | - `options.target.cloudflare.preview` (alias: `options.miniflare`) is |
| 115 | consumed by the harness's preview server only; framework packages should |
| 116 | not read it. |
| 117 | - Anything richer than the shared worker config (framework-specific knobs) |
| 118 | should be taken via the Layer/factory forms (3)/(4) above, where the |
| 119 | fixture calls your typed API directly. |
| 120 | |
| 121 | ### Layer environment |
| 122 | |
| 123 | The Layer is built inside the harness runtime, which provides |
| 124 | `@effect/platform-node`'s `NodeServices` (so `FileSystem.FileSystem` and |
| 125 | `Path.Path` may appear in the Layer's requirements — the |
| 126 | `Options.FrameworkServices` type) and a dotenv/env `ConfigProvider`. The |
| 127 | process working directory is the fixture root. Anything else the layer needs, |
| 128 | it must provide itself. |
| 129 | |
| 130 | Load the _project's_ framework module (its `vite`, `waku`, `astro`, ...) with |
| 131 | framework-core's `loadProjectModule(root, specifier)` / |
| 132 | `resolveProjectPackageDirectory` — never a bare `import` from your own |
| 133 | dependency tree. |
| 134 | |
| 135 | ## The `Framework` service contract |
| 136 | |
| 137 | Defined in `@alchemy.run/frontend-frameworks/core` (`Framework.ts`): |
| 138 | |
| 139 | ```ts |
| 140 | class Framework extends Context.Service< |
| 141 | Framework, |
| 142 | { |
| 143 | readonly build: (options?: FrameworkBuildOptions) => Effect<BuildOutput, FrameworkError>; |
| 144 | readonly dev: ( |
| 145 | options?: FrameworkDevOptions, |
| 146 | ) => Effect<FrameworkDevServer, FrameworkError, Scope.Scope>; // { url: string } |
| 147 | } |
| 148 | >()("@alchemy.run/frontend-frameworks/core/Framework") {} |
| 149 | ``` |
| 150 | |
| 151 | Semantics every implementation must honor: |
| 152 | |
| 153 | - **`build`** runs the framework's production build and returns the |
| 154 | `BuildOutput` contract **purely in-memory** — implementations write nothing |
| 155 | beyond the framework's own build output (no `dist/build.json`; persistence |
| 156 | is the harness's job, see below). Errors surface as `FrameworkError` (set |
| 157 | the `framework` field to your framework name). |
| 158 | - **`dev`** starts the framework's dev server and returns `{ url }`. It is |
| 159 | scoped: closing the `Scope` must stop the server. Honor |
| 160 | `FrameworkDevOptions.port` when given. |
| 161 | - `FrameworkBuildOptions.root` / `FrameworkDevOptions.root` override the |
| 162 | project root (default: the configured root / cwd). |
| 163 | |
| 164 | ### The `dist/build.json` convention (`BuildOutput`) |
| 165 | |
| 166 | `dist/build.json` is the **harness's** E2E persistence mechanism, not part of |
| 167 | the `Framework` contract: `e2e build` persists the returned `BuildOutput` |
| 168 | there itself (framework-core's `writeBuildOutput`), and `e2e preview` reads |
| 169 | it back (framework-core's `readBuildOutput`), so preview stays uniform across |
| 170 | frameworks without any framework writing files into user projects. |
| 171 | |
| 172 | `BuildOutput` (framework-core `BuildOutput.ts`) is the cross-framework build |
| 173 | contract: |
| 174 | |
| 175 | - `clientDirectory` — the static-assets directory, captured **as a path** so |
| 176 | files written after the bundler finishes (SSG HTML, prerendered pages) ride |
| 177 | along. `undefined` if there are no client assets. |
| 178 | - `serverModules` — the worker modules as `OutputFile`s (`name` relative to |
| 179 | `distDirectory`, `content`, sha256 `hash`), **entry module first** |
| 180 | (`sortServerModules` enforces this ordering). `undefined` for assets-only |
| 181 | builds. |
| 182 | - `externalWorkspaces` — workspace roots of modules imported from outside the |
| 183 | project root (for watch/memoization); collect with |
| 184 | `collectExternalWorkspaces`. |
| 185 | - `distDirectory` — the build's root output directory (e.g. `<root>/dist`). |
| 186 | |
| 187 | The harness persists with `writeBuildOutput(path, output)` and reads with |
| 188 | `readBuildOutput(path)` — they handle the JSON round-trip (Buffer revival for |
| 189 | binary modules, `externalWorkspaces` Set). Vite-based frameworks can produce |
| 190 | the whole shape with `makeBuildOutputCollector`; frameworks whose final |
| 191 | bundles land on disk after the bundler (SvelteKit, Next.js) can use |
| 192 | `readServerModulesFromDisk`. |
| 193 | |
| 194 | ## How the harness consumes the contract |
| 195 | |
| 196 | - `e2e build` → `Framework.build()`, then the harness persists the returned |
| 197 | `BuildOutput` to `<fixture>/dist/build.json` (`Server.buildAndPersist`). |
| 198 | - `e2e dev` (and `Server.dev()` / `Playwright.make("dev")`) → |
| 199 | `Framework.dev()`; the harness wraps the returned `url` with fetch helpers. |
| 200 | - `e2e preview` (and `Server.live()` / `Playwright.make("live")`) → reads |
| 201 | `dist/build.json` (framework-core's `readBuildOutput`), falling back to |
| 202 | `Framework.build()` + persist when it is missing or unreadable; then hands |
| 203 | the `BuildOutput` to the cloudflare deploy target's `serve` |
| 204 | (`src/e2e/CloudflareTarget.ts`, a framework-core `DeployTarget` value), which |
| 205 | boots miniflare (`@alchemy.run/cloudflare-test-tools/miniflare`) with the |
| 206 | fixture's `target.cloudflare.preview` options. Serving built output is the |
| 207 | target's concern; the framework implementation is only involved in |
| 208 | producing a correct `BuildOutput`. |