1# @alchemy.run/cloudflare-test-tools/e2e
2
3The e2e harness that drives a fixture's dev server, production build, and
4miniflare preview, and exposes them to Playwright. The harness is
5framework-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
9fixtures need no changes.
10
11## How a fixture uses the harness
12
13A 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
27an `Effect` of it, for config that reads env via `Config`):
28
29```ts
30import * as Options from "@alchemy.run/cloudflare-test-tools/e2e/Options";
31
32export 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
54Config is **target-scoped**: `target.<platform>` carries everything that
55platform's deploy target needs, opaque to the rest of the harness (a future
56AWS target adds `target.aws` without touching the plumbing). The pre-target
57top-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
60merges them (target-scoped wins). Existing fixture configs work unchanged.
61
62Playwright 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
65server. Both dispatch through the `Framework` service.
66
67## The `framework` option
68
69`Options.framework` selects the `Framework`-service implementation. Accepted
70forms:
71
721. **Omitted** — the built-in Vite implementation, configured by
73 `options.vite`. Zero behavior change for existing fixtures.
742. **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.
803. **A factory function** — called with the fixture's full `Options`.
814. **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
87A 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
96import type { Options } from "@alchemy.run/cloudflare-test-tools/e2e/Options";
97import { Framework } from "@alchemy.run/frontend-frameworks/core";
98import * as Layer from "effect/Layer";
99
100export default (options: Options): Layer.Layer<Framework> =>
101 Layer.effect(Framework /* ... build/dev over waku ... */);
102```
103
104### Options flow
105
106The 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
123The 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
127process working directory is the fixture root. Anything else the layer needs,
128it must provide itself.
129
130Load the _project's_ framework module (its `vite`, `waku`, `astro`, ...) with
131framework-core's `loadProjectModule(root, specifier)` /
132`resolveProjectPackageDirectory` — never a bare `import` from your own
133dependency tree.
134
135## The `Framework` service contract
136
137Defined in `@alchemy.run/frontend-frameworks/core` (`Framework.ts`):
138
139```ts
140class 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
151Semantics 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
167the `Framework` contract: `e2e build` persists the returned `BuildOutput`
168there itself (framework-core's `writeBuildOutput`), and `e2e preview` reads
169it back (framework-core's `readBuildOutput`), so preview stays uniform across
170frameworks without any framework writing files into user projects.
171
172`BuildOutput` (framework-core `BuildOutput.ts`) is the cross-framework build
173contract:
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
187The harness persists with `writeBuildOutput(path, output)` and reads with
188`readBuildOutput(path)` — they handle the JSON round-trip (Buffer revival for
189binary modules, `externalWorkspaces` Set). Vite-based frameworks can produce
190the whole shape with `makeBuildOutputCollector`; frameworks whose final
191bundles 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`.