Test sandboxed plugins
@emdash-cms/plugin-test builds a sandboxed plugin and runs its tests through a local Worker Loader binding. Tests use EmDash’s production Cloudflare sandbox wrapper and PluginBridge, with local D1 and Worker Loader bindings supplied by @cloudflare/vitest-plugin.
Choose the host that matches the behavior under test:
createPluginTestHost()invokes a hook or route directly across the sandbox transport. Use it for serialization, capability enforcement, plugin storage, and route-handler logic.createPluginRuntimeTestHost()runs real EmDash content, plugin activation, media, comment, scheduled-task, and plugin-route actions. Use it when the test must prove that a host action reaches the plugin.
Projects created by emdash-plugin init include this setup. Existing plugin projects can install the test host as a development dependency:
pnpm add -D @emdash-cms/plugin-test vitestIf the project restricts dependency build scripts, allow workerd to install its platform binary. The generated pnpm policy includes this entry:
allowBuilds: workerd: trueConfigure Vitest
Section titled “Configure Vitest”Add the EmDash test plugin to the project’s Vitest configuration:
import { emdashPluginTest } from "@emdash-cms/plugin-test/config";import { defineConfig } from "vitest/config";
export default defineConfig({ plugins: [emdashPluginTest()],});emdashPluginTest() runs the plugin build before Vitest starts. It reads the generated runtime and manifest, creates an isolated D1 database and Worker Loader binding, and exports the same PluginBridge used by Cloudflare deployments. Pass { dir: "./packages/gallery" } when the Vitest configuration lives outside the plugin directory.
Test sandbox transport
Section titled “Test sandbox transport”Create and dispose a host inside each test. Disposal stops the plugin and resets its test bindings:
import { afterEach, describe, expect, it } from "vitest";
import { createPluginTestHost, type PluginTestHost } from "@emdash-cms/plugin-test";
let host: PluginTestHost | undefined;
afterEach(async () => { await host?.dispose(); host = undefined;});
describe("health route", () => { it("identifies the plugin", async () => { host = await createPluginTestHost();
await expect(host.invokeRoute("health")).resolves.toEqual({ ok: true, plugin: "save-log", }); });});invokeRoute() accepts an input value and optional request properties. The default request is a POST to the plugin’s route with empty headers and request metadata.
Direct invocation does not exercise EmDash route authentication, permission, token-scope, cross-site request forgery (CSRF), or cache policy. Use host.actions.routes.request() on the runtime host for those checks.
Test hooks and storage
Section titled “Test hooks and storage”Invoke hooks with the event shape they receive from EmDash. The storage and KV readers inspect the state written through the bridge:
host = await createPluginTestHost();
await host.invokeHook("content:afterSave", { collection: "posts", content: { id: "post-1", title: "First post" },});
const events = await host.storage("events").list();expect(events).toHaveLength(1);expect(events[0]?.data).toMatchObject({ collection: "posts", contentId: "post-1",});Storage calls still enforce the collections declared in emdash-plugin.jsonc. Content, media, user, email, and network calls still enforce the plugin’s declared capabilities and allowed hosts.
Seed content
Section titled “Seed content”Create a collection and seed entries before invoking a route or hook that reads site content:
host = await createPluginTestHost();await host.createCollection({ slug: "posts", label: "Posts", fields: [{ slug: "title", label: "Title", type: "string" }],});await host.seedContent("posts", [{ title: "First" }, { title: "Second" }]);
await expect(host.invokeRoute("post-count")).resolves.toEqual({ count: 2 });The collection and entries use the real EmDash schema registry and content repository against D1.
Test host actions
Section titled “Test host actions”Create a runtime host when the result depends on EmDash orchestration. Fixtures write initial state without firing plugin hooks. Actions call the production runtime or handler boundary, and inspectors read observable state without invoking plugin code.
For a plugin whose content:beforeSave hook appends [checked] to the title, the following test proves that a content save reaches the hook:
import { afterEach, describe, expect, it } from "vitest";
import { createPluginRuntimeTestHost, type PluginRuntimeTestHost,} from "@emdash-cms/plugin-test";
let host: PluginRuntimeTestHost | undefined;
afterEach(async () => { await host?.dispose(); host = undefined;});
describe("content save", () => { it("applies the plugin hook", async () => { host = await createPluginRuntimeTestHost(); await host.fixtures.collection({ slug: "posts", label: "Posts", fields: [{ slug: "title", label: "Title", type: "string" }], });
const result = await host.actions.content.create("posts", { data: { title: "First post" }, });
expect(result).toMatchObject({ success: true, data: { item: { data: { title: "First post [checked]" } } }, }); });});The runtime host groups its API by boundary:
transportdirectly invokes the isolate for transport-level checks.adminloads validated Block Kit pages and widgets, submits forms, and invokes actions through the production route boundary with host-attested locale context.fixturescreates site, collection, field, user, byline, taxonomy, content, redirect, binary media, and plugin state without firing hooks.actionsruns content state changes, plugin activation and deactivation, generated settings updates, media uploads, public comment submission, comment moderation, and policy-checked plugin routes.inspectreads content, redirects, byline credits, taxonomy assignments, plugin storage, KV, raw persisted setting envelopes, plugin state, scheduled tasks, publication-policy rejections, media metadata and bytes, comments, and captured email. Useinspect.scheduledPolicyRejections()to verify that a scheduler veto was persisted for administrator attention.scheduledcontrols the effective time for cron tasks and scheduled publishing, then runs one production maintenance batch.httpqueues external responses and captures the requests that a plugin sends throughctx.http.fetch().restart()replaces the runtime and isolate while retaining D1, plugin storage, media storage, and plugin state.
Call dispose() after each test. Disposal terminates the isolate and resets all bindings, so a later test cannot observe the previous host’s database or media.
Use the settings action and raw inspector to prove that a generated settings save reaches the production handler and does not persist plaintext:
const result = await host.actions.plugin.updateSettings({ apiKey: "test-secret" });expect(result).toMatchObject({ success: true, data: { secretsSet: { apiKey: true } } });
const stored = await host.inspect.settings.raw("apiKey");expect(stored).toMatchObject({ v: 1, kid: expect.any(String) });expect(JSON.stringify(stored)).not.toContain("test-secret");Set EMDASH_ENCRYPTION_KEY in the test process before creating the host. The raw inspector deliberately returns the persisted envelope; use the plugin’s route or hook to verify the decrypted ctx.settings value.
Use host.fixtures.redirect() to establish redirect state without invoking the plugin. Use host.inspect.redirects() to assert the persisted rules after the plugin calls ctx.redirects.
Use a binary fixture to test media:bytes:read through the runtime’s storage adapter and Worker Loader bridge. The following fixture deliberately reports a smaller database size so the test can prove that the stream limit is authoritative:
const fixture = await host.fixtures.media({ filename: "sample.bin", mimeType: "application/octet-stream", bytes: new Uint8Array([0, 255, 17, 42]), reportedSize: 1, contentHash: "sha1:sample",});
await expect(host.inspect.mediaBytes(fixture.id)).resolves.toEqual( new Uint8Array([0, 255, 17, 42]),);Use createPluginRuntimeTestHost() for translation creation. The direct transport host does not run the runtime-owned translation lifecycle that copies shared fields and attribution.
Queue a binary response before invoking the plugin route that fetches it:
const admin = await host.fixtures.user({ email: "plugin-test@example.com", role: "admin",});
await host.http.respond( "https://api.example.com/report", new Response(new Uint8Array([0, 255, 195, 40]), { headers: { "content-type": "application/octet-stream" }, }),);
await host.actions.routes.request("import-report", { user: admin, headers: { "X-EmDash-Request": "1" },});
expect(host.http.requests()).toContainEqual( expect.objectContaining({ url: "https://api.example.com/report" }),);respond() consumes and stores the response bytes immediately, so the later Worker Loader request receives a response created in its own request context. Queue another response for each expected call to the same URL. clear() removes queued responses and captured requests.
Pass rawBody to test a declared text, bytes, or form-data request through the production
route parser:
const form = new FormData();form.append("title", "Quarterly report");form.append("attachment", new File([new Uint8Array([0, 255])], "report.bin"));
const admin = await host.fixtures.user({ email: "plugin-test@example.com", role: "admin",});
const response = await host.actions.routes.request("import", { method: "POST", user: admin, headers: { "X-EmDash-Request": "1" }, rawBody: form,});
expect(response.ok).toBe(true);rawBody accepts any BodyInit, including strings, Uint8Array, URLSearchParams, and
FormData. The request body is buffered. Use body for the legacy JSON path; the test host
serializes it and sets Content-Type: application/json.
The runtime host exposes shipped operations only. Capability-specific helpers for translations, publication policy, Block Kit interactions, taxonomies, redirects, expanded comment administration, media bytes, and encrypted settings belong to the releases that add those capabilities.
Test boundaries
Section titled “Test boundaries”The default Vitest configuration uses Worker Loader because it is the fastest production sandbox path for plugin development. EmDash also runs equivalent runtime content and restart journeys against the Node.js workerd runner. Add a separate, opt-in Node/workerd job when a plugin depends on runner-sensitive behavior; the generated project does not run both runners by default.
Neither host renders the EmDash admin application or reproduces Cloudflare’s deployed CPU, memory, and subrequest limits. Use a disposable EmDash site for browser journeys, and verify limit-sensitive behavior on a Cloudflare preview or staging deployment.
For Block Kit handlers, use host.admin.loadPage() or loadWidget() to exercise the private route, host-attested locale context, response validation, and Worker Loader isolate. Use admin.act() and admin.submit() for page interactions.
Saved-entry extensions use the production ownership and route-permission boundary. Create the collection, user, and content with fixtures, then call admin.loadEditorPanel(), actEditorPanel(), submitEditorPanel(), or invokeEditorAction(). Pass locale for the admin UI language and contentLocale when selecting a translated entry. These helpers reload the saved entry before invoking the isolate and never send unsaved field values.
The admin helpers do not render React. Use the Block Playground or a browser journey to verify Kumo rendering, confirmation dialogs, keyboard operation, and right-to-left layout.