branch:
app-e2e.test.ts
26499 bytesRaw
import { describe, it, expect } from "vitest";
import { env } from "cloudflare:workers";
import { createApp } from "../app";
import type { CreateAppOptions } from "../app";
let testId = 0;
/**
* Build an app with createApp, load it into the Worker Loader,
* call fetch(), and return the Response.
*/
async function buildAppAndFetch(
options: CreateAppOptions,
request: Request = new Request("http://app/")
): Promise<Response> {
const result = await createApp(options);
const id = "test-app-" + testId++;
const worker = env.LOADER.get(id, () => ({
mainModule: result.mainModule,
modules: result.modules,
compatibilityDate: result.wranglerConfig?.compatibilityDate ?? "2026-01-01",
compatibilityFlags: result.wranglerConfig?.compatibilityFlags
}));
return worker.getEntrypoint().fetch(request);
}
// ── Basic asset serving ─────────────────────────────────────────────
describe("createApp e2e — static assets", () => {
it("serves a static HTML asset at /", async () => {
const res = await buildAppAndFetch({
files: {
"src/index.ts": [
"export default {",
" fetch() { return new Response('api'); }",
"};"
].join("\n")
},
assets: {
"/index.html": "<!DOCTYPE html><h1>Hello</h1>"
}
});
expect(res.status).toBe(200);
expect(await res.text()).toBe("<!DOCTYPE html><h1>Hello</h1>");
expect(res.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
});
it("serves a JS asset with correct content type", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts": [
"export default {",
" fetch() { return new Response('api'); }",
"};"
].join("\n")
},
assets: {
"/index.html": "<h1>Home</h1>",
"/app.js": "console.log('hello')"
}
},
new Request("http://app/app.js")
);
expect(res.status).toBe(200);
expect(await res.text()).toBe("console.log('hello')");
expect(res.headers.get("Content-Type")).toBe(
"application/javascript; charset=utf-8"
);
});
it("serves CSS with correct content type", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts": [
"export default {",
" fetch() { return new Response('api'); }",
"};"
].join("\n")
},
assets: {
"/styles.css": "body { color: red; }"
}
},
new Request("http://app/styles.css")
);
expect(res.status).toBe(200);
expect(await res.text()).toBe("body { color: red; }");
expect(res.headers.get("Content-Type")).toBe("text/css; charset=utf-8");
});
});
// ── Fall-through to server ──────────────────────────────────────────
describe("createApp e2e — server fall-through", () => {
it("falls through to user Worker for non-asset routes", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts": [
"export default {",
" fetch() { return Response.json({ status: 'ok' }); }",
"};"
].join("\n")
},
assets: {
"/index.html": "<h1>Home</h1>"
}
},
new Request("http://app/api/data")
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({ status: "ok" });
});
it("falls through for POST requests even to asset paths", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts": [
"export default {",
" fetch(req) {",
' return new Response("posted: " + req.method);',
" }",
"};"
].join("\n")
},
assets: {
"/index.html": "<h1>Home</h1>"
}
},
new Request("http://app/index.html", { method: "POST" })
);
expect(res.status).toBe(200);
expect(await res.text()).toBe("posted: POST");
});
it("server can read request URL and headers", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts": [
"export default {",
" fetch(req) {",
" const url = new URL(req.url);",
" return Response.json({",
" path: url.pathname,",
' auth: req.headers.get("Authorization")',
" });",
" }",
"};"
].join("\n")
},
assets: {}
},
new Request("http://app/api/users", {
headers: { Authorization: "Bearer token123" }
})
);
const data = (await res.json()) as { path: string; auth: string };
expect(data.path).toBe("/api/users");
expect(data.auth).toBe("Bearer token123");
});
});
// ── ETag / conditional requests ─────────────────────────────────────
describe("createApp e2e — ETag and caching", () => {
it("includes ETag in asset responses", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/app.js": "console.log('hello')"
}
},
new Request("http://app/app.js")
);
expect(res.status).toBe(200);
const etag = res.headers.get("ETag");
expect(etag).toBeTruthy();
expect(etag!.startsWith('"')).toBe(true);
});
it("returns 304 for matching If-None-Match", async () => {
const options: CreateAppOptions = {
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/app.js": "console.log('hello')"
}
};
// First request to get ETag
const result = await createApp(options);
const id = "test-app-etag-" + testId++;
const worker = env.LOADER.get(id, () => ({
mainModule: result.mainModule,
modules: result.modules,
compatibilityDate: "2026-01-01"
}));
const first = await worker
.getEntrypoint()
.fetch(new Request("http://app/app.js"));
const etag = first.headers.get("ETag")!;
// Second request with If-None-Match
const second = await worker.getEntrypoint().fetch(
new Request("http://app/app.js", {
headers: { "If-None-Match": etag }
})
);
expect(second.status).toBe(304);
});
it("sets Cache-Control must-revalidate for HTML", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/index.html": "<h1>Home</h1>"
}
},
new Request("http://app/")
);
expect(res.headers.get("Cache-Control")).toBe(
"public, max-age=0, must-revalidate"
);
});
it("sets Cache-Control immutable for hashed assets", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/app.a1b2c3d4e5f6.js": "console.log('versioned')"
}
},
new Request("http://app/app.a1b2c3d4e5f6.js")
);
expect(res.headers.get("Cache-Control")).toBe(
"public, max-age=31536000, immutable"
);
});
});
// ── HTML handling ───────────────────────────────────────────────────
describe("createApp e2e — HTML handling", () => {
it("serves /about via /about.html (auto-trailing-slash)", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/about.html": "<h1>About</h1>"
}
},
new Request("http://app/about")
);
expect(res.status).toBe(200);
expect(await res.text()).toBe("<h1>About</h1>");
});
it("serves /blog/ via /blog/index.html", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/blog/index.html": "<h1>Blog</h1>"
}
},
new Request("http://app/blog/")
);
expect(res.status).toBe(200);
expect(await res.text()).toBe("<h1>Blog</h1>");
});
});
// ── SPA fallback ────────────────────────────────────────────────────
describe("createApp e2e — SPA fallback", () => {
it("serves /index.html for unknown routes with SPA config", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/index.html": "<!DOCTYPE html><div id='root'></div>"
},
assetConfig: {
not_found_handling: "single-page-application"
}
},
new Request("http://app/dashboard/settings", {
headers: { Accept: "text/html,application/xhtml+xml" }
})
);
expect(res.status).toBe(200);
expect(await res.text()).toBe("<!DOCTYPE html><div id='root'></div>");
});
it("falls through to server for non-HTML requests (API calls)", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/index.html": "<!DOCTYPE html><div id='root'></div>"
},
assetConfig: {
not_found_handling: "single-page-application"
}
},
new Request("http://app/api/counter")
);
expect(res.status).toBe(200);
expect(await res.text()).toBe("api");
});
it("still serves exact assets over SPA fallback", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/index.html": "<h1>Home</h1>",
"/app.js": "console.log('app')"
},
assetConfig: {
not_found_handling: "single-page-application"
}
},
new Request("http://app/app.js")
);
expect(res.status).toBe(200);
expect(await res.text()).toBe("console.log('app')");
});
});
// ── Client bundling ─────────────────────────────────────────────────
describe("createApp e2e — client bundling", () => {
it("bundles a client entry and serves it as an asset", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts": [
"export default {",
" fetch() { return new Response('server'); }",
"};"
].join("\n"),
"src/client.ts": [
'const msg: string = "hello from client";',
"console.log(msg);"
].join("\n")
},
client: "src/client.ts",
assets: {
"/index.html": '<script src="/client.js"></script>'
}
},
new Request("http://app/client.js")
);
expect(res.status).toBe(200);
const text = await res.text();
// The bundled output should contain the string from the source
expect(text).toContain("hello from client");
expect(res.headers.get("Content-Type")).toBe(
"application/javascript; charset=utf-8"
);
});
it("serves the HTML page that references the client bundle", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts": [
"export default {",
" fetch() { return new Response('server'); }",
"};"
].join("\n"),
"src/client.ts": 'console.log("app");'
},
client: "src/client.ts",
assets: {
"/index.html": '<!DOCTYPE html><script src="/client.js"></script>'
}
},
new Request("http://app/")
);
expect(res.status).toBe(200);
expect(await res.text()).toBe(
'<!DOCTYPE html><script src="/client.js"></script>'
);
});
});
// ── Multiple assets ─────────────────────────────────────────────────
describe("createApp e2e — multiple assets", () => {
it("serves multiple different asset types", async () => {
const options: CreateAppOptions = {
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/index.html": "<h1>Home</h1>",
"/app.js": "console.log('app')",
"/styles.css": "body { margin: 0; }",
"/data.json": '{"key":"value"}',
"/robots.txt": "User-agent: *"
}
};
const result = await createApp(options);
const id = "test-app-multi-" + testId++;
const worker = env.LOADER.get(id, () => ({
mainModule: result.mainModule,
modules: result.modules,
compatibilityDate: "2026-01-01"
}));
const ep = worker.getEntrypoint();
const html = await ep.fetch(new Request("http://app/"));
expect(html.status).toBe(200);
expect(html.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
const js = await ep.fetch(new Request("http://app/app.js"));
expect(js.status).toBe(200);
expect(await js.text()).toBe("console.log('app')");
const css = await ep.fetch(new Request("http://app/styles.css"));
expect(css.status).toBe(200);
expect(await css.text()).toBe("body { margin: 0; }");
const json = await ep.fetch(new Request("http://app/data.json"));
expect(json.status).toBe(200);
expect(await json.text()).toBe('{"key":"value"}');
const txt = await ep.fetch(new Request("http://app/robots.txt"));
expect(txt.status).toBe(200);
expect(await txt.text()).toBe("User-agent: *");
// API route falls through
const api = await ep.fetch(new Request("http://app/api/data"));
expect(api.status).toBe(200);
expect(await api.text()).toBe("api");
});
});
// ── Error cases ─────────────────────────────────────────────────────
describe("createApp error cases", () => {
it("throws when server entry is not found", async () => {
await expect(
createApp({
files: { "src/other.ts": "export const x = 1;" },
server: "src/index.ts"
})
).rejects.toThrow('Server entry point "src/index.ts" not found');
});
it("throws when client entry is not found", async () => {
await expect(
createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
client: "src/client.ts"
})
).rejects.toThrow('Client entry point "src/client.ts" not found');
});
});
// ── Output structure ────────────────────────────────────────────────
describe("createApp output structure", () => {
it("returns __app-wrapper.js as mainModule", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: { "/index.html": "<h1>Hi</h1>" }
});
expect(result.mainModule).toBe("__app-wrapper.js");
});
it("includes asset manifest in modules", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: {
"/index.html": "<h1>Hi</h1>",
"/app.js": "console.log('hi')"
}
});
expect(result.modules["__asset-manifest.json"]).toBeDefined();
});
it("includes asset modules with __assets/ prefix", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: {
"/index.html": "<h1>Hi</h1>",
"/app.js": "console.log('hi')"
}
});
expect(result.modules["__assets/index.html"]).toBeDefined();
expect(result.modules["__assets/app.js"]).toBeDefined();
});
it("populates assetMap", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: {
"/index.html": "<h1>Hi</h1>"
}
});
expect(result.assetManifest.size).toBe(1);
expect(result.assetManifest.get("/index.html")).toBeDefined();
});
it("reports clientBundles when client entry is provided", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };",
"src/client.ts": "console.log('hi')"
},
client: "src/client.ts",
assets: {}
});
expect(result.clientBundles).toBeDefined();
expect(result.clientBundles).toContain("/client.js");
});
});
// ── 404-page handling ────────────────────────────────────────────────
describe("createApp e2e — 404-page not-found handling", () => {
it("serves 404.html with status 404", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/index.html": "<h1>Home</h1>",
"/404.html": "<h1>Not Found</h1>"
},
assetConfig: {
not_found_handling: "404-page"
}
},
new Request("http://app/nonexistent", {
headers: { Accept: "text/html" }
})
);
expect(res.status).toBe(404);
expect(await res.text()).toBe("<h1>Not Found</h1>");
});
it("serves nested 404.html walking up directories", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/index.html": "<h1>Home</h1>",
"/blog/404.html": "<h1>Blog Not Found</h1>"
},
assetConfig: {
not_found_handling: "404-page"
}
},
new Request("http://app/blog/missing-post", {
headers: { Accept: "text/html" }
})
);
expect(res.status).toBe(404);
expect(await res.text()).toBe("<h1>Blog Not Found</h1>");
});
it("falls through to server when no 404.html exists", async () => {
const res = await buildAppAndFetch(
{
files: {
"src/index.ts":
"export default { fetch() { return new Response('api'); } };"
},
assets: {
"/index.html": "<h1>Home</h1>"
},
assetConfig: {
not_found_handling: "404-page"
}
},
new Request("http://app/nonexistent")
);
expect(res.status).toBe(200);
expect(await res.text()).toBe("api");
});
});
// ── Wrapper code structure ──────────────────────────────────────────
describe("createApp — wrapper code structure", () => {
it("module wrapper imports handleAssetRequest from runtime module", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: { "/index.html": "<h1>Hi</h1>" }
});
const wrapper = result.modules["__app-wrapper.js"] as string;
expect(wrapper).toContain(
'import { handleAssetRequest, createMemoryStorage } from "./__asset-runtime.js"'
);
expect(wrapper).toContain(
"await handleAssetRequest(request, manifest, storage, ASSET_CONFIG)"
);
});
it("DO wrapper imports handleAssetRequest from runtime module", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: { "/index.html": "<h1>Hi</h1>" },
durableObject: true
});
const wrapper = result.modules["__app-wrapper.js"] as string;
expect(wrapper).toContain(
'import { handleAssetRequest, createMemoryStorage } from "./__asset-runtime.js"'
);
expect(wrapper).toContain(
"await handleAssetRequest(request, manifest, storage, ASSET_CONFIG)"
);
});
it("includes __asset-runtime.js in output modules", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: { "/index.html": "<h1>Hi</h1>" }
});
const runtime = result.modules["__asset-runtime.js"];
expect(typeof runtime).toBe("string");
expect(runtime as string).toContain("handleAssetRequest");
expect(runtime as string).toContain("createMemoryStorage");
});
it("wrapper initializes manifest Map and storage at module level", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: { "/index.html": "<h1>Hi</h1>" }
});
const wrapper = result.modules["__app-wrapper.js"] as string;
expect(wrapper).toContain("new Map(Object.entries(manifestJson))");
expect(wrapper).toContain("createMemoryStorage(ASSET_CONTENT)");
});
it("module and DO wrappers share the same init block", async () => {
const moduleResult = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: { "/index.html": "<h1>Hi</h1>" }
});
const doResult = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: { "/index.html": "<h1>Hi</h1>" },
durableObject: true
});
const moduleWrapper = moduleResult.modules["__app-wrapper.js"] as string;
const doWrapper = doResult.modules["__app-wrapper.js"] as string;
// Extract the init block (ASSET_CONFIG through storage creation)
const extractInit = (code: string) => {
const start = code.indexOf("const ASSET_CONFIG");
const end =
code.indexOf("createMemoryStorage(ASSET_CONTENT)") +
"createMemoryStorage(ASSET_CONTENT);".length;
return code.slice(start, end).trim();
};
expect(extractInit(moduleWrapper)).toBe(extractInit(doWrapper));
});
});
// ── Durable Object wrapper ──────────────────────────────────────────
describe("createApp — durableObject option", () => {
it("sets durableObjectClassName to 'App' with durableObject: true", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: { "/index.html": "<h1>Hi</h1>" },
durableObject: true
});
expect(result.durableObjectClassName).toBe("App");
});
it("uses custom className from durableObject option", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: { "/index.html": "<h1>Hi</h1>" },
durableObject: { className: "MyApp" }
});
expect(result.durableObjectClassName).toBe("MyApp");
});
it("does not set durableObjectClassName without the option", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: { "/index.html": "<h1>Hi</h1>" }
});
expect(result.durableObjectClassName).toBeUndefined();
});
it("wrapper imports DurableObject from cloudflare:workers", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: { "/index.html": "<h1>Hi</h1>" },
durableObject: true
});
const wrapper = result.modules["__app-wrapper.js"];
expect(typeof wrapper).toBe("string");
expect(wrapper as string).toContain(
'import { DurableObject } from "cloudflare:workers"'
);
});
it("wrapper exports named class with correct name", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: {},
durableObject: { className: "Counter" }
});
const wrapper = result.modules["__app-wrapper.js"] as string;
expect(wrapper).toContain("export class Counter extends BaseClass");
});
it("wrapper uses runtime handleAssetRequest and super.fetch fallback", async () => {
const result = await createApp({
files: {
"src/index.ts":
"export default { fetch() { return new Response('ok'); } };"
},
assets: { "/index.html": "<h1>Hi</h1>" },
durableObject: true
});
const wrapper = result.modules["__app-wrapper.js"] as string;
expect(wrapper).toContain(
"handleAssetRequest(request, manifest, storage, ASSET_CONFIG)"
);
expect(wrapper).toContain("super.fetch(request)");
});
});